ctrlrelay 0.17.1__tar.gz → 0.19.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/CHANGELOG.md +156 -6
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/PKG-INFO +1 -1
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/config/orchestrator.yaml.example +67 -4
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/configuration.md +231 -1
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/operations.md +17 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/personalization.md +166 -7
- ctrlrelay-0.19.0/docs/registered-mode.md +476 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/pyproject.toml +1 -1
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/cli.py +106 -14
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/__init__.py +8 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/code_review.py +16 -2
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/config.py +241 -6
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/dispatcher.py +857 -320
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/node_check.py +16 -2
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/worktree.py +390 -33
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/personalization/manager.py +541 -12
- ctrlrelay-0.19.0/src/ctrlrelay/personalization/merge.py +533 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/dev.py +625 -198
- ctrlrelay-0.19.0/src/ctrlrelay/web/numbers.py +275 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/static/index.html +103 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/status.py +32 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/_publish_fakes.py +2 -0
- ctrlrelay-0.19.0/tests/test_agent_backend_wiring.py +264 -0
- ctrlrelay-0.19.0/tests/test_agent_routing.py +397 -0
- ctrlrelay-0.19.0/tests/test_dev_step_switches.py +1004 -0
- ctrlrelay-0.19.0/tests/test_dev_work_steps.py +214 -0
- ctrlrelay-0.19.0/tests/test_opencode_adapter.py +1044 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_personalization.py +69 -3
- ctrlrelay-0.19.0/tests/test_personalization_merge.py +1608 -0
- ctrlrelay-0.19.0/tests/test_relay_git_263.py +621 -0
- ctrlrelay-0.19.0/tests/test_review_copy.py +421 -0
- ctrlrelay-0.19.0/tests/test_web_numbers.py +426 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_web_server.py +1 -1
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_web_status.py +1 -1
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.gitignore +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/LICENSE +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/README.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/SECURITY.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/cli.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/development.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/index.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/fleet.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/server.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/_poller_fakes.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_agent_no_keys.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_archived.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_events.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_fleet.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_poller_search.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_publish_failure_retry.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_redact.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_usage.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_web_waiting.py +0 -0
- {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,155 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.19.0] - 2026-11-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `repos[].steps` sets each dev pipeline work step (`code`, `publish`,
|
|
15
|
+
`verify`, `review`) to `off`, `on` or `required`, and `repos[].max_fix_attempts`
|
|
16
|
+
caps the CI fix rounds. `required` stops the run on a failure, including a
|
|
17
|
+
reviewer that cannot run or CI that never finished; the PR stays open and the
|
|
18
|
+
run is announced as failed. Unset keys keep today's behaviour, and settings
|
|
19
|
+
the pipeline could not honour (`code: off`, `verify: on`, a review switch
|
|
20
|
+
that disagrees with `code_review.method`) are refused at load (#226).
|
|
21
|
+
- `mode: merge` for a personalization path keeps the live file real and, on
|
|
22
|
+
pull, writes only the top-level JSON keys listed in the local `merge_keys`,
|
|
23
|
+
so `~/.claude/settings.json` can carry fleet policy while the keys the app
|
|
24
|
+
writes on its own stay on that machine and no longer dirty the checkout. The
|
|
25
|
+
synced file cannot widen the list; it must be a JSON object without
|
|
26
|
+
duplicate keys; links (at the file or any directory under `$HOME` on the
|
|
27
|
+
way), non-regular and foreign-owned files are refused; writes are atomic at
|
|
28
|
+
mode 0600 or stricter and are redone once if the app writes the file
|
|
29
|
+
meanwhile; push refuses while the checkout copy differs from HEAD (`migrate
|
|
30
|
+
--reset-copy` drops the change) and a configuration with no merge entry
|
|
31
|
+
pushes exactly as before; logs name keys, never values, and a change to
|
|
32
|
+
`hooks`, `env`, `permissions`, `apiKeyHelper` or
|
|
33
|
+
`skipDangerousModePermissionPrompt` logs a warning. `ctrlrelay
|
|
34
|
+
personalization migrate` converts the old symlink once, on request, and
|
|
35
|
+
resets the checkout copy to the fleet's version; pull and auto-pull refuse
|
|
36
|
+
the symlink instead (#287).
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- A personalization pull on a machine still wired the old way (`~/.claude/settings.json`
|
|
41
|
+
a symlink into the checkout, with the app having written through it) failed
|
|
42
|
+
with "rebase hit conflicts" and never mentioned the fix. Pull and auto-pull
|
|
43
|
+
now add: run `ctrlrelay personalization migrate` (#287)
|
|
44
|
+
|
|
45
|
+
### Security
|
|
46
|
+
|
|
47
|
+
- The relay's own git touches of agent-written trees no longer reach past
|
|
48
|
+
the agent sandbox (#263).
|
|
49
|
+
- Every `git worktree add` the relay runs now reads no global or system
|
|
50
|
+
git config and runs no hooks or fsmonitor, whether or not the sandbox is
|
|
51
|
+
on. A committed or planted attributes file therefore cannot select a
|
|
52
|
+
filter driver the operator's config defines. For that checkout this
|
|
53
|
+
drops `lfs.*` and `filter.*` drivers, post-checkout hooks,
|
|
54
|
+
`safe.directory`, `core.autocrlf`/`core.eol`, `core.longpaths` and
|
|
55
|
+
`url.*.insteadOf` (not needed: the add does no fetch).
|
|
56
|
+
- A repository using git-lfs now gives the agent pointer files. None of
|
|
57
|
+
the repositories the relay currently works on uses LFS: 0 of 90 bare
|
|
58
|
+
clones have `filter=lfs` in HEAD's `.gitattributes`.
|
|
59
|
+
- The bare clone's own config is still read. The relay writes it, and the
|
|
60
|
+
sandbox keeps it read-only.
|
|
61
|
+
- The `CLAUDE.md` exclude line is written only to the worktree's git
|
|
62
|
+
directory found from the path the relay made (never from the
|
|
63
|
+
worktree's `.git` file): `<clone>.git/worktrees/<basename>[N]`, whose
|
|
64
|
+
back-pointer must name this worktree. It is opened without following
|
|
65
|
+
any link, a hard-linked file is refused, and every refusal is logged
|
|
66
|
+
and skipped rather than failing the resume.
|
|
67
|
+
- The reviewer's base branch and its commit come from GitHub rather than
|
|
68
|
+
the bare clone, are pinned in the review copy, and are named in the
|
|
69
|
+
review comment.
|
|
70
|
+
|
|
71
|
+
## [0.18.0] - 2026-11-03
|
|
72
|
+
|
|
73
|
+
### Added
|
|
74
|
+
|
|
75
|
+
- A third agent backend: `agent.type: "opencode"` runs sessions through
|
|
76
|
+
`opencode run --format json --dangerously-skip-permissions` — the spelling
|
|
77
|
+
every opencode from 1.4 to 2.x accepts; its `--auto` alias exists only from
|
|
78
|
+
1.17.12 — which can target a self-hosted OpenAI-compatible endpoint
|
|
79
|
+
(ollama, llama.cpp, LM Studio, MLX) and so lets the dev pipeline run on a
|
|
80
|
+
local model instead of a hosted API. The endpoint, its credential and the
|
|
81
|
+
model list stay in opencode's own config; ctrlrelay names the model with
|
|
82
|
+
`agent.opencode.model` (`provider/model`) and the binary with
|
|
83
|
+
`agent.opencode.binary` — its own block, because `agent.binary` is claude's
|
|
84
|
+
and most configs set it to the literal `"claude"`.
|
|
85
|
+
- `repos[].agent_type` opts a single repository into another backend, with
|
|
86
|
+
`agent.type` the default for every other one. Per repo rather than global on
|
|
87
|
+
purpose: ctrlrelay works unattended over issue and pull-request text from
|
|
88
|
+
many repositories and opens pull requests, so a weaker model behind the
|
|
89
|
+
whole daemon would produce bad pull requests at machine speed with nobody
|
|
90
|
+
watching. An unroutable session goes to the default backend, never to the
|
|
91
|
+
opt-in one, and repo names match case-insensitively. A value no adapter
|
|
92
|
+
answers to stops the daemon at startup naming the repository, the same way
|
|
93
|
+
an unknown `agent.type` already did (closes #286)
|
|
94
|
+
- The status page and `/api/status` show how this node's runs went over
|
|
95
|
+
the last 7 and 30 days, overall and per repo: runs started, done,
|
|
96
|
+
failed, blocked and still running, success rate, median time to PR and
|
|
97
|
+
to merge, and issues dispatched again after a done run. Cost per run and
|
|
98
|
+
stuck runs are shown as not recorded yet (refs #230)
|
|
99
|
+
|
|
100
|
+
### Changed
|
|
101
|
+
|
|
102
|
+
- Internal, no behaviour change: the dev pipeline's work (code, publish,
|
|
103
|
+
verify, review) runs as a fixed, ordered list of steps inside a frame that
|
|
104
|
+
keeps the lock, claim, worktree, cleanup and claim reconciliation as they
|
|
105
|
+
were. Groundwork for per-repo step switches (refs #226)
|
|
106
|
+
- The subprocess machinery every CLI-driven adapter shares — the timeout, the
|
|
107
|
+
kill on cancel, the replacing decode, the checkpoint read and #204's outcome
|
|
108
|
+
report on every exit path — moved from `ClaudeDispatcher` to a
|
|
109
|
+
`CliAgentAdapter` base both adapters inherit. A second copy of it is how the
|
|
110
|
+
next backend gets dispatch failures that nothing counts.
|
|
111
|
+
- `ctrlrelay node check` skips its agent-login row, and says why, when no
|
|
112
|
+
repository routes to `claude` — the global `agent.type` is not `claude` and
|
|
113
|
+
no `repos[].agent_type` names it. That check is a `claude -p` call reading
|
|
114
|
+
the claude subscription, so on another backend it reported a failed login
|
|
115
|
+
against a CLI the deployment does not use; with even one repo still on
|
|
116
|
+
`claude` it runs, so an expired login shows up there and not as that repo's
|
|
117
|
+
next failed job.
|
|
118
|
+
|
|
119
|
+
### Fixed
|
|
120
|
+
|
|
121
|
+
- The code review no longer runs in the agent's worktree. It runs in a
|
|
122
|
+
fresh clone of the commit the relay pushed, in a new temporary
|
|
123
|
+
directory, removed afterwards. Before, a nested repository the agent
|
|
124
|
+
left in its worktree (a gitlink with its own `.git` and config) made a
|
|
125
|
+
plain `git status` or `git diff` there run a program the agent chose,
|
|
126
|
+
in the reviewer's process, outside the agent sandbox. The copy also
|
|
127
|
+
switches off filter and diff drivers from the operator's own git
|
|
128
|
+
config (for example git-lfs or a textconv), so a committed
|
|
129
|
+
`.gitattributes` cannot point agent files at them. If no clean copy can
|
|
130
|
+
be made, the PR is announced as not reviewed (`copy_failed`); it never
|
|
131
|
+
falls back to the worktree (#263)
|
|
132
|
+
- Personalization auto-pull no longer reports success when it could not
|
|
133
|
+
sync. A dirty working tree now fails the run, logged at error level with
|
|
134
|
+
the dirty entries; every run reports `behind N` (a dirty tree still
|
|
135
|
+
fetches to measure it). `push` pre-flights the git identity and refuses
|
|
136
|
+
with `git identity unresolvable` instead of leaving a dirty tree.
|
|
137
|
+
`docs/personalization.md` describes the open question of app-rewritten
|
|
138
|
+
files such as `settings.json` (refs #287)
|
|
139
|
+
|
|
140
|
+
### Known limits
|
|
141
|
+
|
|
142
|
+
- The `opencode` backend writes no usage reading. A self-hosted model has no
|
|
143
|
+
subscription window, and recording "unknown" after every run would erase a
|
|
144
|
+
reading the claude backend holds and un-pause a machine that is at its limit.
|
|
145
|
+
- `agent.sandbox: "bwrap"` is **refused**, not ignored, alongside an `opencode`
|
|
146
|
+
backend: that confinement binds `~/.claude/*` writable and copies
|
|
147
|
+
`~/.claude.json`, so it is written for one CLI. Running unconfined under a
|
|
148
|
+
config that says otherwise would be worse than a daemon that will not start.
|
|
149
|
+
Credential stripping (#219) applies to every backend either way.
|
|
150
|
+
- Whether a given local model can drive the pipeline is a separate question
|
|
151
|
+
from whether the adapter is correct, and this change does not answer it.
|
|
152
|
+
Measured on an M1 Max on 2026-11-01: `qwen2.5-coder:32b-instruct-q4_K_M`
|
|
153
|
+
emitted **no tool calls at all** (0/3) while advertising `tools` in its
|
|
154
|
+
metadata, and `qwen3-coder:30b` emitted them (3/3). ctrlrelay's agents signal
|
|
155
|
+
DONE / BLOCKED / FAILED by running a shell command that writes the checkpoint
|
|
156
|
+
file, so a model that cannot call tools cannot finish a session. A declared
|
|
157
|
+
capability is not evidence; only a live call is.
|
|
158
|
+
|
|
10
159
|
## [0.17.1] - 2026-11-01
|
|
11
160
|
|
|
12
161
|
### Fixed
|
|
@@ -484,7 +633,7 @@ daemon with any of it.
|
|
|
484
633
|
rewind even when every other ref applied. `ensure_bare_repo` read that
|
|
485
634
|
exit code as failure and aborted the session.
|
|
486
635
|
|
|
487
|
-
Observed on
|
|
636
|
+
Observed on one of the swept repositories, whose sweep died reporting
|
|
488
637
|
`WorktreeError: git failed: ... ! [rejected] ... (non-fast-forward)`
|
|
489
638
|
in the same fetch that had just advanced `main`. Twelve bare repos
|
|
490
639
|
were holding a `dependabot/*` ref at the time, each one primed to fail
|
|
@@ -515,8 +664,7 @@ daemon with any of it.
|
|
|
515
664
|
`✅ Scheduled secops sweep done: N/M ok`, which fires once every repo
|
|
516
665
|
has been swept. On the 2026-09-09 sweep that was hours after the reply
|
|
517
666
|
and named no repo: four answers merged 20 Dependabot PRs across
|
|
518
|
-
|
|
519
|
-
`aiproxyguard-promptest`, and not one outbound message went back to
|
|
667
|
+
four repositories, and not one outbound message went back to
|
|
520
668
|
the operator. Answering looked indistinguishable from being ignored.
|
|
521
669
|
|
|
522
670
|
The inline path now reports the same three outcomes the sweeper
|
|
@@ -1052,7 +1200,7 @@ during the dogfood validation:
|
|
|
1052
1200
|
|
|
1053
1201
|
- **`setup` no longer lists the personalization repo under
|
|
1054
1202
|
`repos:`.** When the operator's personalization repo lives under
|
|
1055
|
-
one of the configured owners (e.g. `alice/
|
|
1203
|
+
one of the configured owners (e.g. `alice/agent-config` while `alice`
|
|
1056
1204
|
is also an enumerated owner), it was being added to the dev
|
|
1057
1205
|
pipeline's monitored set — the poller would have polled it for
|
|
1058
1206
|
issues and worktree-cloned it. The repo is the cross-machine sync
|
|
@@ -1214,7 +1362,7 @@ personalization flow.
|
|
|
1214
1362
|
### Docs
|
|
1215
1363
|
|
|
1216
1364
|
- **[Personalization sync](docs/personalization.md)** (#125). New
|
|
1217
|
-
page (nav order 8) covering setup, the
|
|
1365
|
+
page (nav order 8) covering setup, the personalization repo layout, the
|
|
1218
1366
|
init/status/push/pull lifecycle, `--no-adopt`, auto-pull cron,
|
|
1219
1367
|
multi-machine bootstrap, and the gotchas (worktrees, edit-through-
|
|
1220
1368
|
symlink semantics, allowlist enforcement, conflict handling, strict
|
|
@@ -2109,7 +2257,9 @@ pipeline).
|
|
|
2109
2257
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
2110
2258
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
2111
2259
|
|
|
2112
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.
|
|
2260
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.0...HEAD
|
|
2261
|
+
[0.19.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.18.0...v0.19.0
|
|
2262
|
+
[0.18.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.17.1...v0.18.0
|
|
2113
2263
|
[0.17.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.17.0...v0.17.1
|
|
2114
2264
|
[0.17.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.4...v0.17.0
|
|
2115
2265
|
[0.16.4]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.3...v0.16.4
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.19.0
|
|
4
4
|
Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
|
|
5
5
|
Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
|
|
6
6
|
Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
|
|
@@ -21,16 +21,52 @@ paths:
|
|
|
21
21
|
# AInvirion: AINVIRION # GitHub owner -> on-disk folder name
|
|
22
22
|
# SemClone: SEMCL.ONE
|
|
23
23
|
|
|
24
|
-
# Headless coding agent. `type` selects the adapter
|
|
25
|
-
# "
|
|
26
|
-
#
|
|
27
|
-
#
|
|
24
|
+
# Headless coding agent. `type` selects the adapter: "claude" and
|
|
25
|
+
# "opencode" are implemented, and codex/hermes/kiro are still planned.
|
|
26
|
+
# An unimplemented value stops the daemon at startup rather than falling
|
|
27
|
+
# back to claude, so a typo here is loud. Legacy `claude:` key is still
|
|
28
|
+
# accepted as an alias for backwards compatibility but is deprecated.
|
|
29
|
+
#
|
|
30
|
+
# `type` is the DEFAULT for every repo. A single repo can opt into
|
|
31
|
+
# another backend with `repos[].agent_type` — see the repos block at the
|
|
32
|
+
# bottom of this file. That is the intended shape: claude everywhere,
|
|
33
|
+
# one low-stakes repo on a self-hosted model, rather than a global flip.
|
|
28
34
|
agent:
|
|
29
35
|
type: "claude"
|
|
36
|
+
# claude's own binary and nothing else's. The opencode block below has
|
|
37
|
+
# its own, because most configs set this to the literal "claude" and
|
|
38
|
+
# a second backend reading it would exec the wrong program.
|
|
30
39
|
binary: "claude"
|
|
31
40
|
default_timeout_seconds: 1800
|
|
32
41
|
output_format: "json"
|
|
33
42
|
|
|
43
|
+
# Read only when a backend of type "opencode" is built, so this block
|
|
44
|
+
# is inert on a claude-only deployment and harmless to leave in place.
|
|
45
|
+
#
|
|
46
|
+
# `model` is "provider/model" as opencode spells it. Point a provider
|
|
47
|
+
# at a self-hosted OpenAI-compatible endpoint in opencode's own config
|
|
48
|
+
# (~/.config/opencode/opencode.json), e.g.
|
|
49
|
+
#
|
|
50
|
+
# {"provider": {"local": {"npm": "@ai-sdk/openai-compatible",
|
|
51
|
+
# "options": {"baseURL": "http://127.0.0.1:11434/v1"},
|
|
52
|
+
# "models": {"qwen3-coder:30b": {}}}}}
|
|
53
|
+
#
|
|
54
|
+
# and name it here as "local/qwen3-coder:30b". The endpoint, its
|
|
55
|
+
# credential and the model list live there, not in ctrlrelay — leaving
|
|
56
|
+
# `model` unset lets opencode pick, which is usually what you want.
|
|
57
|
+
#
|
|
58
|
+
# One measured caveat before choosing a model: a model can advertise
|
|
59
|
+
# tool support and emit no tool calls at all. ctrlrelay's agents
|
|
60
|
+
# signal DONE / BLOCKED / FAILED by RUNNING A SHELL COMMAND that
|
|
61
|
+
# writes the checkpoint file, so a model that cannot call tools cannot
|
|
62
|
+
# finish a session however good its prose is. Test with a live call.
|
|
63
|
+
# opencode:
|
|
64
|
+
# binary: "opencode"
|
|
65
|
+
# model: "local/qwen3-coder:30b"
|
|
66
|
+
# # Falls back to agent.default_timeout_seconds when unset. A local
|
|
67
|
+
# # model is usually slower per token than a hosted one.
|
|
68
|
+
# default_timeout_seconds: 3600
|
|
69
|
+
|
|
34
70
|
# EXACTLY ONE transport is active, and it is the one named by `type`.
|
|
35
71
|
# The other blocks below are inert: they may stay in the file, but nothing
|
|
36
72
|
# reads them. Running Telegram and Mattermost at the same time is NOT
|
|
@@ -184,6 +220,16 @@ repos: []
|
|
|
184
220
|
# - name: "owner/repo"
|
|
185
221
|
# # Optional when paths.repo_root is set; otherwise required.
|
|
186
222
|
# local_path: "~/Projects/repo"
|
|
223
|
+
# # Which agent backend runs THIS repo. Omit for agent.type, which
|
|
224
|
+
# # is where every repo is unless it says otherwise. A value no
|
|
225
|
+
# # adapter answers to stops the daemon at startup naming this repo.
|
|
226
|
+
# #
|
|
227
|
+
# # Opt in per repo rather than globally. ctrlrelay works unattended
|
|
228
|
+
# # over issue and pull-request text and opens pull requests, so a
|
|
229
|
+
# # weaker model behind the whole daemon produces bad pull requests
|
|
230
|
+
# # at machine speed with nobody watching. Put a low-stakes repo
|
|
231
|
+
# # here; leave the ones that matter on claude.
|
|
232
|
+
# agent_type: "opencode"
|
|
187
233
|
# automation:
|
|
188
234
|
# dependabot_patch: auto
|
|
189
235
|
# dependabot_minor: ask
|
|
@@ -208,3 +254,20 @@ repos: []
|
|
|
208
254
|
# # generated while blocked in this call, so a slow default here
|
|
209
255
|
# # just adds idle wall-clock time. Defaults to 600 (10 minutes).
|
|
210
256
|
# ci_wait_timeout_seconds: 600
|
|
257
|
+
# # How strict each dev pipeline work step is for this repo. The
|
|
258
|
+
# # order (code, publish, verify, review) is fixed. Each key is
|
|
259
|
+
# # off, on or required; leave a key out for today's behaviour.
|
|
260
|
+
# # required: a failure stops the run, and nothing skips the step
|
|
261
|
+
# # (a reviewer that cannot run is not a pass, and CI
|
|
262
|
+
# # that never finished is not a pass)
|
|
263
|
+
# # on: a failure is reported and the PR is handed over
|
|
264
|
+
# # off: the step does not run
|
|
265
|
+
# # code and publish accept only required. verify accepts off or
|
|
266
|
+
# # required. review: off is the same as code_review.method: off;
|
|
267
|
+
# # set both only if they agree.
|
|
268
|
+
# steps:
|
|
269
|
+
# verify: required
|
|
270
|
+
# review: required
|
|
271
|
+
# # Fix rounds for a PR whose CI is red or whose branch conflicts,
|
|
272
|
+
# # before the run fails. Defaults to 3; 0 means none.
|
|
273
|
+
# max_fix_attempts: 3
|
|
@@ -70,6 +70,124 @@ paths:
|
|
|
70
70
|
| `repo_root` | path | no | Convention root for repo clones. When set, `repos[].local_path` may be omitted and is derived as `${repo_root}/${owner.lower()}/${repo}` (since v0.4.0). Without `repo_root`, every repo entry must declare its own `local_path` (legacy behaviour). |
|
|
71
71
|
| `owner_aliases` | object | no | **Deprecated since v0.4.0.** Pre-0.4.0 this remapped the on-disk folder name (e.g. `SemClone` -> `SEMCL.ONE`). The path resolver now always uses `owner.lower()`, so aliases are no longer consulted. Parsing is retained so 0.3.x configs still load; a `DeprecationWarning` is emitted when a non-empty mapping is supplied. To silence: drop the `owner_aliases` block, rename your on-disk folder to match `owner.lower()`, or set a per-repo `local_path` override for any that genuinely deviate. |
|
|
72
72
|
|
|
73
|
+
## agent backends
|
|
74
|
+
|
|
75
|
+
`agent.type` picks the adapter ctrlrelay uses to talk to a coding-agent
|
|
76
|
+
CLI. Two are implemented:
|
|
77
|
+
|
|
78
|
+
| `agent.type` | CLI it runs | notes |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `claude` (default) | `claude -p --output-format stream-json --verbose --dangerously-skip-permissions` | Reads the subscription usage; supports `sandbox: "bwrap"`. |
|
|
81
|
+
| `opencode` | `opencode run --format json --dangerously-skip-permissions` | Can target a self-hosted OpenAI-compatible endpoint. No usage reading; no bwrap yet. |
|
|
82
|
+
|
|
83
|
+
Anything else raises at startup with a message naming the type. That is
|
|
84
|
+
deliberate: a typo must stop the daemon rather than silently fall back
|
|
85
|
+
to claude.
|
|
86
|
+
|
|
87
|
+
### Per-repo opt-in
|
|
88
|
+
|
|
89
|
+
`agent.type` is the default for every repo. A single repo opts into
|
|
90
|
+
another backend with `repos[].agent_type`:
|
|
91
|
+
|
|
92
|
+
```yaml
|
|
93
|
+
agent:
|
|
94
|
+
type: "claude" # everything, unless a repo says otherwise
|
|
95
|
+
opencode:
|
|
96
|
+
model: "local/qwen3-coder:30b"
|
|
97
|
+
|
|
98
|
+
repos:
|
|
99
|
+
- name: "your-org/important-service" # claude
|
|
100
|
+
- name: "your-org/scratchpad"
|
|
101
|
+
agent_type: "opencode" # the local model
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Per repo rather than globally, on purpose. ctrlrelay runs unattended
|
|
105
|
+
over issue and pull-request text from many repositories and opens pull
|
|
106
|
+
requests. A weaker model behind the whole daemon produces bad pull
|
|
107
|
+
requests at machine speed with nobody watching, so the shape that is
|
|
108
|
+
safe is claude everywhere plus an explicit opt-in for low-stakes work.
|
|
109
|
+
|
|
110
|
+
Repo names are matched case-insensitively — GitHub treats `owner/repo`
|
|
111
|
+
that way and the config, an issue payload and `gh` output do not
|
|
112
|
+
reliably agree on the case.
|
|
113
|
+
|
|
114
|
+
### agent.opencode
|
|
115
|
+
|
|
116
|
+
```yaml
|
|
117
|
+
agent:
|
|
118
|
+
type: "claude"
|
|
119
|
+
opencode:
|
|
120
|
+
binary: "opencode"
|
|
121
|
+
model: "local/qwen3-coder:30b"
|
|
122
|
+
default_timeout_seconds: 3600
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
| Key | Type | Default | Description |
|
|
126
|
+
|---|---|---|---|
|
|
127
|
+
| `binary` | string | `"opencode"` | The `opencode` executable. The bare name is resolved like every other tool (PATH, then `~/.local/bin`, `/usr/local/bin`, `/opt/homebrew/bin`), which matters under systemd where the unit's PATH is minimal. An absolute or relative path is used exactly as written. |
|
|
128
|
+
| `model` | string | `null` | `provider/model` as opencode spells it, passed as `--model`. Unset leaves the choice to opencode's own config, which is usually right. |
|
|
129
|
+
| `default_timeout_seconds` | int | `null` | Falls back to `agent.default_timeout_seconds`. A local model is usually slower per token than a hosted one. |
|
|
130
|
+
|
|
131
|
+
This block is its own rather than a reuse of `agent.binary`: that field
|
|
132
|
+
is claude's, most deployed configs set it to the literal `"claude"`, and
|
|
133
|
+
a second backend reading it would exec `claude` with `opencode`'s
|
|
134
|
+
arguments — a failure that looks like the model's fault.
|
|
135
|
+
|
|
136
|
+
**The endpoint is configured in opencode, not here.** Point a provider
|
|
137
|
+
at your self-hosted OpenAI-compatible server in
|
|
138
|
+
`~/.config/opencode/opencode.json`:
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"provider": {
|
|
143
|
+
"local": {
|
|
144
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
145
|
+
"options": { "baseURL": "http://127.0.0.1:11434/v1" },
|
|
146
|
+
"models": { "qwen3-coder:30b": {} }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
and name it here as `local/qwen3-coder:30b`. The credential, if the
|
|
153
|
+
endpoint needs one, lives there too.
|
|
154
|
+
|
|
155
|
+
### What `opencode` does not do yet
|
|
156
|
+
|
|
157
|
+
- **No usage reading.** A self-hosted model has no subscription window.
|
|
158
|
+
The adapter writes nothing to `usage.json` — not even "unknown" —
|
|
159
|
+
because marking it unknown after every run would erase a reading the
|
|
160
|
+
claude backend legitimately holds and un-pause a machine that is
|
|
161
|
+
genuinely at its limit. On a deployment with no claude backend at all,
|
|
162
|
+
`usage` stays empty and the idle probe does not run.
|
|
163
|
+
- **No `sandbox: "bwrap"`.** The confinement binds `~/.claude/*`
|
|
164
|
+
writable and copies `~/.claude.json`: it is written for one CLI.
|
|
165
|
+
Combining `agent.sandbox: "bwrap"` with an `opencode` backend is
|
|
166
|
+
**refused at startup** rather than silently run unconfined. Set
|
|
167
|
+
`agent.sandbox: "none"` to use it. Credential stripping (#219) applies
|
|
168
|
+
to every backend either way.
|
|
169
|
+
- **`ctrlrelay node check` skips the agent-login row** when no
|
|
170
|
+
repository routes to `claude` — the global `agent.type` is not
|
|
171
|
+
`claude` and no `repos[].agent_type` names it — because that check is
|
|
172
|
+
a `claude` call reading the claude subscription. With a global
|
|
173
|
+
`opencode` and even one repo on `claude`, the row still runs. The check
|
|
174
|
+
never probes `opencode` itself: verify it by running its CLI once by
|
|
175
|
+
hand.
|
|
176
|
+
|
|
177
|
+
### Picking a model for `opencode`
|
|
178
|
+
|
|
179
|
+
One result worth knowing before you choose, measured on an M1 Max on
|
|
180
|
+
2026-11-01: **a model can advertise tool support and emit no tool calls
|
|
181
|
+
at all.** `qwen2.5-coder:32b-instruct-q4_K_M` scored 0/3 on tool calling
|
|
182
|
+
while declaring `tools` in its metadata and shipping a tool-aware
|
|
183
|
+
template; `qwen3-coder:30b` scored 3/3.
|
|
184
|
+
|
|
185
|
+
That matters more here than it looks. ctrlrelay's agents signal `DONE`,
|
|
186
|
+
`BLOCKED_NEEDS_INPUT` and `FAILED` by **running a shell command that
|
|
187
|
+
writes the checkpoint file** — so a model that cannot call tools cannot
|
|
188
|
+
finish a session at all, however good its prose is. Test with a live
|
|
189
|
+
call; a declared capability is not evidence.
|
|
190
|
+
|
|
73
191
|
## claude
|
|
74
192
|
|
|
75
193
|
Controls how ctrlrelay invokes the `claude` CLI.
|
|
@@ -413,7 +531,7 @@ web:
|
|
|
413
531
|
|---|---|---|---|
|
|
414
532
|
| `host` | string | `"127.0.0.1"` | Address to listen on, exactly. Loopback by default so a fresh install is never reachable from other machines. Set it to this machine's LAN address to open the page from the home network. `0.0.0.0` listens on every interface and is warned about at startup. |
|
|
415
533
|
| `port` | int (1-65535) | `8787` | Port to listen on. |
|
|
416
|
-
| `history_days` | int (>= 1) | `7` | Days of feed the page shows, and what `/api/events` returns without `?since=`. |
|
|
534
|
+
| `history_days` | int (>= 1) | `7` | Days of feed the page shows, and what `/api/events` returns without `?since=`. Events older than this are deleted, so the run numbers' time to PR and time to merge are shown only for a window whose runs all started after the oldest event kept. Set it to `30` to get the 30-day ones; they appear once 30 days of events have been kept. |
|
|
417
535
|
| `refresh_seconds` | int (>= 1) | `5` | How often the page asks `/api/status` again. |
|
|
418
536
|
| `github_check_seconds` | int (>= 15) | `300` | How often the waiting line asks GitHub (one search, plus one query per `include_labels` label). Every check spends from the GitHub account's budget, which machines on the account share. |
|
|
419
537
|
| `stale_after_minutes` | int (>= 1) | `15` | A running card is marked "looks stuck" after this long with no new agent step, unless ctrlrelay itself has a CI wait or review open for it. |
|
|
@@ -537,8 +655,11 @@ repos:
|
|
|
537
655
|
| `name` | string | **yes** | — | GitHub `owner/repo` slug. Used for `gh` calls and bare-repo / worktree naming. |
|
|
538
656
|
| `local_path` | path | conditional | derived | Where the repo is checked out on disk for human use. Optional when `paths.repo_root` is set (then derived as `${repo_root}/${owner.lower()}/${repo}`, since v0.4.0); required otherwise. An explicit value always wins as override. ctrlrelay itself uses bare mirrors under `paths.bare_repos`. |
|
|
539
657
|
| `dev_branch_template` | string | no | `"fix/issue-{n}"` | Branch-name template for dev-pipeline runs. `{n}` is replaced by the issue number. |
|
|
658
|
+
| `agent_type` | string | no | `null` | Which agent backend runs this repo's sessions. `null` means `agent.type`. See [agent backends](#agent-backends). A value no adapter answers to stops the daemon at startup, naming this repo. |
|
|
540
659
|
| `automation` | object | no | (defaults) | See [automation](#repos-automation). |
|
|
541
660
|
| `code_review` | object | no | (defaults) | Review run over an agent branch before its PR is handed over. See [code_review](#code_review). |
|
|
661
|
+
| `steps` | object | no | (all unset) | How strict each dev pipeline work step is. See [steps](#repossteps). |
|
|
662
|
+
| `max_fix_attempts` | integer ≥ 0 | no | `3` | Fix rounds the dev pipeline asks for when a PR's CI is red or its branch conflicts, before the run fails. `0` means none. See [steps](#repossteps). |
|
|
542
663
|
| `deploy` | object | no | `null` | Reserved for deploy policy. Currently surfaced in `ctrlrelay config repos` but otherwise inert. |
|
|
543
664
|
|
|
544
665
|
### repos[].automation
|
|
@@ -737,6 +858,109 @@ repos:
|
|
|
737
858
|
- This does not change the overall session timeout (`agent.default_timeout_seconds`,
|
|
738
859
|
default 1800s) — it only bounds the one `ci wait` call.
|
|
739
860
|
|
|
861
|
+
### repos[].steps
|
|
862
|
+
|
|
863
|
+
The dev pipeline does its work in a fixed order: **code** (the agent run and
|
|
864
|
+
any questions it asks), **publish** (the relay pushes the branch and opens the
|
|
865
|
+
pull request), **verify** (wait for CI and mergeability, asking the agent to
|
|
866
|
+
fix what fails, up to `max_fix_attempts` rounds) and **review** (the
|
|
867
|
+
configured reviewer reads the pushed commit and posts its findings). The order
|
|
868
|
+
is not configurable. `steps` says how strict each step is for this repo.
|
|
869
|
+
Only the dev pipeline reads it. The task and secops pipelines have no
|
|
870
|
+
switchable steps (see [below](#task-and-secops-have-no-step-switches)), so
|
|
871
|
+
`steps` never changes how a `task`-labelled issue or a secops sweep runs.
|
|
872
|
+
|
|
873
|
+
```yaml
|
|
874
|
+
repos:
|
|
875
|
+
- name: "your-org/your-repo"
|
|
876
|
+
steps:
|
|
877
|
+
verify: required
|
|
878
|
+
review: required
|
|
879
|
+
max_fix_attempts: 2
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
| Mode | Meaning |
|
|
883
|
+
|---|---|
|
|
884
|
+
| `off` | The step does not run. |
|
|
885
|
+
| `on` | A failure is reported and the run continues: the PR is handed over. |
|
|
886
|
+
| `required` | A failure stops the run, and no route skips the step. A step that cannot run has not passed. |
|
|
887
|
+
| (unset) | What the pipeline did before these switches. See the table below. |
|
|
888
|
+
|
|
889
|
+
| Step | Accepts | Unset means |
|
|
890
|
+
|---|---|---|
|
|
891
|
+
| `code` | `required` | A failed or crashed agent run stops the run. Same as `required`. |
|
|
892
|
+
| `publish` | `required` | A refused or failed push stops the run (the branch is kept). Same as `required`. |
|
|
893
|
+
| `verify` | `off`, `required` | CI still red after the fix rounds stops the run. CI that is still pending when the wait times out hands the PR over. |
|
|
894
|
+
| `review` | `off`, `on`, `required` | `on`: a review that did not post is announced as `NO REVIEW ON THIS PR` and the PR is handed over. |
|
|
895
|
+
|
|
896
|
+
`code` and `publish` cannot be `off` or `on`: a dev run without them has
|
|
897
|
+
nothing to hand over, so the config refuses those values at load instead of
|
|
898
|
+
accepting and ignoring them. `verify: on` is refused too: it would announce a
|
|
899
|
+
PR as ready while its CI is red, with no line in the announcement saying so.
|
|
900
|
+
Unknown step names (a typo such as `verfy`) are refused as well. A bare `on`
|
|
901
|
+
or `off` works unquoted.
|
|
902
|
+
|
|
903
|
+
**`verify: required`** also fails the run when the CI wait times out, before
|
|
904
|
+
or after a fix round. (Checks that cannot be read at all fail the run in every
|
|
905
|
+
mode that waits on CI, not only under `required`.)
|
|
906
|
+
**`verify: off`** skips the CI wait and the fix rounds; the review still runs.
|
|
907
|
+
The PR is announced as ready without anyone having looked at its CI — the
|
|
908
|
+
same thing `verify: on` is refused for, chosen deliberately here by turning
|
|
909
|
+
verify off. The status page has no CI events for the run; `pr_opened` is
|
|
910
|
+
still recorded, once, when the PR is published.
|
|
911
|
+
|
|
912
|
+
**`review: required`** passes only when the review's findings were posted on
|
|
913
|
+
the PR. A reviewer that cannot start, one that ran without reading the diff, a
|
|
914
|
+
comment that failed to post, a copy of the commit that could not be made, and
|
|
915
|
+
an exception inside the review are all failures. It is about the review
|
|
916
|
+
having happened, not about what it found. **`review: off`** is the same as
|
|
917
|
+
`code_review.method: off`, and loads as that.
|
|
918
|
+
|
|
919
|
+
Two settings for one reviewer must agree, or the config refuses to load:
|
|
920
|
+
|
|
921
|
+
- `code_review.method: off` with `steps.review: on` or `required`;
|
|
922
|
+
- `steps.review: off` with an explicit `code_review.method: cli`;
|
|
923
|
+
- `steps.review: required` with `code_review.comment_on_pr: false`, since the
|
|
924
|
+
findings would never reach the PR.
|
|
925
|
+
|
|
926
|
+
`code_review.method: off` on its own keeps working as before.
|
|
927
|
+
|
|
928
|
+
**What the operator sees when a required step stops a run after the PR
|
|
929
|
+
exists.** The ordinary failure path, unchanged:
|
|
930
|
+
|
|
931
|
+
- the PR stays open on GitHub, and the relay keeps watching it for a merge;
|
|
932
|
+
- the session is marked `failed`; the worktree is removed and the pushed
|
|
933
|
+
branch kept;
|
|
934
|
+
- the claim on the issue is retracted with a comment that names the PR and
|
|
935
|
+
the reason (for example `PR #42 not handed over: review is required for
|
|
936
|
+
your-org/your-repo and did not pass`);
|
|
937
|
+
- chat gets `❌ Failed on #N: …`, never `✅ PR ready`.
|
|
938
|
+
|
|
939
|
+
A resumed session (an answer that arrived after the run stopped to ask) goes
|
|
940
|
+
through the same publish and verify steps. It did not run the review before
|
|
941
|
+
these switches, and with `review` unset or `on` it still does not; with
|
|
942
|
+
`review: required` it does.
|
|
943
|
+
|
|
944
|
+
#### Task and secops have no step switches
|
|
945
|
+
|
|
946
|
+
Each of their steps already behaves as `required` or as a best-effort side
|
|
947
|
+
effect, and neither `off` nor `on` would mean something the relay can honour:
|
|
948
|
+
|
|
949
|
+
- **Task, the agent run.** A task run without it has nothing to report.
|
|
950
|
+
Only `required` would make sense, and that is what it already does.
|
|
951
|
+
- **Task, posting the report.** A DONE with no report, or a report the relay
|
|
952
|
+
cannot post, fails the run today. `off` would lose the report: chat gets
|
|
953
|
+
only the one-line summary. `on` would announce `✅ Task done` and keep the
|
|
954
|
+
claim on an issue with no report, which looks the same as a crash.
|
|
955
|
+
- **Secops, the sweep.** One agent session reads the alerts and pull requests
|
|
956
|
+
and merges what the policy allows. The merges happen inside that session,
|
|
957
|
+
not in a relay step, so a switch could not hold them back. The switch for
|
|
958
|
+
them already exists: `automation.dependabot_patch`, `dependabot_minor` and
|
|
959
|
+
`dependabot_major` (`auto`, `ask` or `never`).
|
|
960
|
+
- **Secops, reading earlier answers.** If the 30-day decision history cannot
|
|
961
|
+
be read, the sweep runs without it. `off` would drop the operator's answers
|
|
962
|
+
and let the policy merge a pull request they said to leave open.
|
|
963
|
+
|
|
740
964
|
## schedules
|
|
741
965
|
|
|
742
966
|
In-process cron jobs run by the poller daemon. All expressions are
|
|
@@ -774,6 +998,10 @@ personalization:
|
|
|
774
998
|
target: "~/.claude/CLAUDE.md"
|
|
775
999
|
- source: "global/skills/"
|
|
776
1000
|
target: "~/.claude/skills/"
|
|
1001
|
+
- source: "global/settings.json"
|
|
1002
|
+
target: "~/.claude/settings.json"
|
|
1003
|
+
mode: merge
|
|
1004
|
+
merge_keys: [model, outputStyle, hooks]
|
|
777
1005
|
- source: "claude-memory/${PROJECT}/"
|
|
778
1006
|
target: "~/.claude/projects/${PROJECT_ENCODED}/memory/"
|
|
779
1007
|
project_scoped: true
|
|
@@ -802,6 +1030,8 @@ agree.
|
|
|
802
1030
|
| `source` | string | **yes** | Path inside the personalization repo. Trailing slash means directory. |
|
|
803
1031
|
| `target` | string | **yes** | On-disk path the symlink will be created at. Supports `${HOME}` and (when `project_scoped`) `${PROJECT}`, `${PROJECT_ENCODED}`, `${PROJECT_LOCAL}`, `${PROJECT_PARENT}`. |
|
|
804
1032
|
| `project_scoped` | bool | no (default `false`) | When `true`, the entry expands once per repo in `repos:`, with the `${PROJECT_*}` placeholders resolved against that repo's `local_path`. |
|
|
1033
|
+
| `mode` | string | no (default `"symlink"`) | `symlink` links the target into the checkout. `merge` keeps the target a real file and, on pull, writes into it only the top-level JSON keys in `merge_keys`; for files an app also rewrites, such as `~/.claude/settings.json`. A single file only, not `project_scoped`. See [Files an app rewrites in place]({{ '/personalization/' | relative_url }}). |
|
|
1034
|
+
| `merge_keys` | list of strings | with `mode: merge` | The top-level keys the fleet owns in that file. Local config only: the synced file cannot add to it. Required and non-empty for `merge`; not allowed for `symlink`. |
|
|
805
1035
|
|
|
806
1036
|
The `${PROJECT}` slug uses **`<owner>--<repo>`** with a double
|
|
807
1037
|
hyphen so `a-b/c` and `a/b-c` produce different slugs. `${PROJECT_ENCODED}`
|
|
@@ -331,6 +331,23 @@ Added in #249:
|
|
|
331
331
|
| `node.open_questions` | How many questions are waiting for an answer. |
|
|
332
332
|
| `fleet` | `{peers: [...]}` with one item per `fleet.peers` URL: `url`, `name`, `reachable`, `checked_at`, `error`, `state`, `node` (that peer's `node` block), `last_known` (its last block while it does not answer) and `needs_you`. `null` when no peers are configured. |
|
|
333
333
|
|
|
334
|
+
Added in #230: `numbers` is how this machine's runs went, for the last 7 and
|
|
335
|
+
the last 30 days (`numbers.windows`, each with `days`, `since`/`since_ts`,
|
|
336
|
+
`until`/`until_ts`). The page shows it in the **Numbers** box, with a table
|
|
337
|
+
per repo under it. Numbers across machines are not here; they belong to the
|
|
338
|
+
control plane. Every field is defined in `src/ctrlrelay/web/numbers.py`.
|
|
339
|
+
|
|
340
|
+
| Field (in `overall` and in each `by_repo` item) | What |
|
|
341
|
+
|---|---|
|
|
342
|
+
| `started` | Runs (rows in the sessions table) started in the window, both ends included. A blocked run resumed after an answer is still one run. |
|
|
343
|
+
| `done`, `failed`, `blocked`, `running`, `other` | How many of those runs have that status now. `running` includes a run left behind by a daemon that stopped mid-run. The five add up to `started`. |
|
|
344
|
+
| `success_rate` | `done / (done + failed + blocked)`, 0 to 1; `null` when none has finished. |
|
|
345
|
+
| `time_to_pr_s`, `time_to_merge_s` | `{median, n}`: seconds from the run's start to its first `pr_opened` (or `merged`) event, over the `n` runs that have one. Events stamped after now are not read. `null` when events cannot be read, or when a run in the window started before the oldest event still kept: its PR event may have been deleted. The daemon deletes events older than `web.history_days` (7 by default), so with the default the 30-day times are `null`. **To get them, set `web.history_days` to 30**; they appear once the events kept reach back past every run in the window (up to 30 days after the change, since deleted events do not come back). |
|
|
346
|
+
| `came_back` | Distinct issues dispatched again in the window after an earlier run on the same issue had ended `done`. The window's `came_back_issues` lists them (`repo`, `issue_number`, `url`; at most 50). An issue reopened on GitHub and not picked up again is not counted: ctrlrelay does not record reopening. |
|
|
347
|
+
| `cost_per_run`, `stuck` | Always `null` for now: cost per run is not recorded yet (#223), and no run records that it was stuck (#224). The window's `unavailable` says this, and why any other field is `null`. |
|
|
348
|
+
|
|
349
|
+
`numbers` is `null`, with a line in `errors`, when the sessions cannot be read.
|
|
350
|
+
|
|
334
351
|
**The page with peers.** A **Machines** strip shows one card per machine,
|
|
335
352
|
this one first: state, slots, 5-hour and 7-day usage, heartbeat age. A card is
|
|
336
353
|
red when the machine is paused, silent, unknown, not reachable, or not read
|