ctrlrelay 0.15.0__tar.gz → 0.16.1__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.15.0 → ctrlrelay-0.16.1}/CHANGELOG.md +140 -1
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/PKG-INFO +5 -5
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/README.md +4 -4
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/SECURITY.md +1 -1
- ctrlrelay-0.16.1/docs/adding-a-machine.md +120 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/cli.md +2 -2
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/configuration.md +205 -17
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/development.md +2 -2
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/getting-started.md +4 -4
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/index.md +2 -2
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/operations.md +227 -1
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/pyproject.toml +1 -1
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/__main__.py +8 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/mattermost_handler.py +13 -0
- ctrlrelay-0.16.1/src/ctrlrelay/bridge/node_tag.py +50 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/server.py +110 -4
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/telegram_handler.py +52 -1
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/cli.py +496 -4
- ctrlrelay-0.16.1/src/ctrlrelay/core/agent_env.py +528 -0
- ctrlrelay-0.16.1/src/ctrlrelay/core/claims.py +628 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/config.py +155 -6
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/dispatcher.py +196 -24
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/github.py +142 -1
- ctrlrelay-0.16.1/src/ctrlrelay/core/github_budget.py +416 -0
- ctrlrelay-0.16.1/src/ctrlrelay/core/heartbeat.py +99 -0
- ctrlrelay-0.16.1/src/ctrlrelay/core/job_claims.py +154 -0
- ctrlrelay-0.16.1/src/ctrlrelay/core/node_check.py +217 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/poller.py +112 -3
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/scheduler.py +57 -0
- ctrlrelay-0.16.1/src/ctrlrelay/core/usage.py +465 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/worktree.py +150 -23
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/pipelines/dev.py +128 -12
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/pipelines/secops.py +37 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/pipelines/task.py +75 -2
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/systemd/bridge.service.template +1 -1
- ctrlrelay-0.16.1/src/ctrlrelay/web/fleet.py +515 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/server.py +80 -4
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/static/index.html +288 -8
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/status.py +71 -2
- ctrlrelay-0.16.1/tests/fixtures/stream_json_rate_limit.jsonl +4 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_agent_no_keys.py +4 -17
- ctrlrelay-0.16.1/tests/test_agent_sandbox.py +500 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_bridge_server.py +38 -16
- ctrlrelay-0.16.1/tests/test_bridge_start_command.py +63 -0
- ctrlrelay-0.16.1/tests/test_chat_machines.py +407 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_claim_reconciliation.py +39 -1
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_config.py +2 -2
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_dev_integration.py +4 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_dev_pipeline.py +15 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_dispatch_outcome_hook.py +17 -16
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_dispatcher.py +19 -19
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_event_emission.py +11 -0
- ctrlrelay-0.16.1/tests/test_fleet.py +549 -0
- ctrlrelay-0.16.1/tests/test_github_budget.py +584 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_install.py +11 -0
- ctrlrelay-0.16.1/tests/test_job_claims.py +385 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_mattermost_handler.py +1 -1
- ctrlrelay-0.16.1/tests/test_node_check.py +394 -0
- ctrlrelay-0.16.1/tests/test_node_claims.py +1031 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_obs.py +4 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_pipeline_base.py +1 -0
- ctrlrelay-0.16.1/tests/test_pre_dispatch_retry.py +257 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_question_backoff.py +2 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_relay_alert_off_lock.py +1 -1
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_secops_integration.py +2 -1
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_secops_pipeline.py +21 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_task_pipeline.py +2 -0
- ctrlrelay-0.16.1/tests/test_usage.py +627 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_web_server.py +64 -3
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_web_status.py +48 -2
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_worktree.py +379 -18
- ctrlrelay-0.15.0/src/ctrlrelay/core/agent_env.py +0 -228
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.gitignore +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/LICENSE +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/Gemfile +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/_config.yml +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/architecture.md +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/bridge.md +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/personalization.md +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/__init__.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/conftest.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_archived.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_audit.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_events.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_github.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_network.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_poller.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_redact.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_setup.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_state.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_transport.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_web_waiting.py +0 -0
|
@@ -7,6 +7,143 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.16.1] - 2026-11-01
|
|
11
|
+
|
|
12
|
+
Fixes the chat bridge, which did not start on 0.16.0. **No config edit is
|
|
13
|
+
required.** If you upgraded to 0.16.0, upgrade to this.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- `ctrlrelay bridge start` (what the bridge service runs) exited at startup
|
|
18
|
+
on 0.16.0 with `BridgeServer.__init__() missing 1 required keyword-only
|
|
19
|
+
argument: 'node'`, for Telegram and Mattermost alike, so chat questions
|
|
20
|
+
and answers stopped. It now passes the machine's name and the shared
|
|
21
|
+
setting, like `python -m ctrlrelay.bridge` (#265)
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- The bridge's systemd unit is described as "ctrlrelay chat bridge
|
|
26
|
+
(Telegram or Mattermost)", and the docs call it the chat bridge. An
|
|
27
|
+
installed unit keeps its old description until it is re-rendered (#266)
|
|
28
|
+
|
|
29
|
+
## [0.16.0] - 2026-11-01
|
|
30
|
+
|
|
31
|
+
Several machines can now run ctrlrelay on one GitHub account, each claiming
|
|
32
|
+
an issue before it works it, and the dev and task agents can run in a
|
|
33
|
+
read-only sandbox. Every new config value has a default and nothing is added
|
|
34
|
+
to the database, so for most installs **no config edit is required**. Check
|
|
35
|
+
three things before upgrading:
|
|
36
|
+
|
|
37
|
+
- **A placeholder `node_id` is refused at startup**: `localhost`,
|
|
38
|
+
`localhost.localdomain`, `unknown`, `my-laptop`, `studio-mac` (both were
|
|
39
|
+
examples in earlier docs), `changeme`, `example`, `node`, `hostname`. Set a
|
|
40
|
+
name no other machine on the account uses. A missing `node_id` still
|
|
41
|
+
defaults to the hostname.
|
|
42
|
+
- **If you already run `agent.sandbox: bwrap`**, it is now much stricter:
|
|
43
|
+
by default the agent can write its worktree, its git dirs, a private
|
|
44
|
+
`/tmp`, its own empty `gh` and runtime dirs, the entries of `~/.claude`,
|
|
45
|
+
its own cache and any `sandbox_writable_paths`, minus the read-only and
|
|
46
|
+
hidden paths inside those. Anything else a run wrote
|
|
47
|
+
(a cache in your home, `git config`, submodules, commit signing) fails
|
|
48
|
+
there. It also refuses to start with `CLAUDE_CONFIG_DIR` set or with a
|
|
49
|
+
read-only path that is a replaceable link. Read the sandbox section of the
|
|
50
|
+
configuration docs before upgrading such a machine.
|
|
51
|
+
- **Upgrade every machine on an account to the same version.** Claims now
|
|
52
|
+
name the machine and the process that wrote them, and an older version
|
|
53
|
+
reads them differently. A claim comment left by an older version counts
|
|
54
|
+
as taken by `unknown` and never goes stale: once nothing is working that
|
|
55
|
+
issue, delete the comment to free it. With a shared Mattermost bot, a
|
|
56
|
+
question asked before the upgrade carries no machine tag: answer it with
|
|
57
|
+
`node-a: ...`.
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- Several machines on one account: before a dev or task run, the machine
|
|
62
|
+
claims the issue with a comment naming itself, waits
|
|
63
|
+
`claims.race_wait_seconds` and backs off if another machine's claim came
|
|
64
|
+
first; claims are refreshed while the run lasts and taken over once stale
|
|
65
|
+
(`claims.refresh_seconds`, `claims.stale_after_seconds`). A run whose
|
|
66
|
+
machine cannot refresh its claim for longer than the stale time (a long
|
|
67
|
+
GitHub outage) can be taken over while it still runs, so keep the
|
|
68
|
+
machines' clocks synchronised and the stale time well above any outage
|
|
69
|
+
you expect. A run that fails after being resumed from a question leaves
|
|
70
|
+
its claim held; delete the claim comment to free that issue (#252,
|
|
71
|
+
closes #245)
|
|
72
|
+
- The scheduled security sweep runs at most once per repository and
|
|
73
|
+
scheduled time across machines: the first to create the ref
|
|
74
|
+
`refs/ctrlrelay/claims/secops/<window>` in the repository runs it. A
|
|
75
|
+
machine that dies holding a window, or a ref that cannot be created,
|
|
76
|
+
leaves that repository unswept for that window; the next window is new.
|
|
77
|
+
Keep `repos`, `schedules` and `timezone` the same on every machine, or
|
|
78
|
+
they name different windows (#259, closes #246)
|
|
79
|
+
- Chat with several machines: questions are tagged with the machine's name
|
|
80
|
+
and a machine ignores replies to another's. Mattermost machines can share
|
|
81
|
+
one bot and channel with `transport.mattermost.shared: true` (a message
|
|
82
|
+
that is not a reply must start with `node-b: ...`); Telegram needs one bot
|
|
83
|
+
per machine. When a second process starts reading the same bot, Telegram
|
|
84
|
+
cuts off the one already running, which now says so once, in its log and
|
|
85
|
+
in the chat, and stops reading (#260, closes #248)
|
|
86
|
+
- Subscription usage: read from each agent run and shown on the status
|
|
87
|
+
page. New issues wait while the 5-hour or 7-day window is at or above
|
|
88
|
+
`usage.pause_at` (default 90%), or the status is anything but `allowed`,
|
|
89
|
+
until the window's reset (or an hour, when the reset time cannot be read,
|
|
90
|
+
and then the next reading decides); runs already going finish, and a usage that
|
|
91
|
+
could not be read never pauses. An idle probe every `usage.probe_minutes`
|
|
92
|
+
keeps the reading fresh (#254, closes #247)
|
|
93
|
+
- GitHub API budget: watched every `github_api.check_minutes`; polling slows
|
|
94
|
+
by `github_api.slow_factor` while the `core` or `graphql` bucket is below
|
|
95
|
+
`github_api.low_at` (not `search`, which resets every minute), with one
|
|
96
|
+
chat line. When a bucket is used up, calls known to spend it
|
|
97
|
+
(`gh api`, `gh search`) fail at once as rate limited until its reset, and
|
|
98
|
+
the poller resumes after it; other `gh` commands are not held back (#256,
|
|
99
|
+
closes #237)
|
|
100
|
+
- Status page: a Machines strip and who-has-what for every machine listed in
|
|
101
|
+
`fleet.peers` (each machine's `web.allowed_hosts` must hold the name the
|
|
102
|
+
others use for it), plus this machine's usage and API budget (#258,
|
|
103
|
+
closes #249)
|
|
104
|
+
- `ctrlrelay node check [--no-agent]`: node id, GitHub login, agent login
|
|
105
|
+
and peers, exit 1 on any failure; the poller warns at startup and while
|
|
106
|
+
running if another process uses this machine's `node_id`; new guide
|
|
107
|
+
`docs/adding-a-machine.md` (#261, closes #250)
|
|
108
|
+
- `agent.sandbox: bwrap` is now a read-only copy of the machine: by default
|
|
109
|
+
only the run's worktree, its git dirs, a private `/tmp`, the entries of
|
|
110
|
+
`~/.claude` and an agent-only cache (`agent.sandbox_cache_dir`) are
|
|
111
|
+
writable; the
|
|
112
|
+
agent CLI's settings, hooks, plugins, skills and project memory, and the
|
|
113
|
+
repository's git config and hooks, stay read-only; more credentials and
|
|
114
|
+
the session's sockets are hidden; `~/.claude.json` is a private copy per
|
|
115
|
+
run. New `agent.sandbox_writable_paths` and `agent.sandbox_readonly_paths`.
|
|
116
|
+
Still off by default. The configuration docs list what it costs a run
|
|
117
|
+
(no git config changes, no commit signing, no submodule updates) and how
|
|
118
|
+
to set it up (#262, closes #236)
|
|
119
|
+
- Status page: PR and issue numbers in Needs you questions are links (#242,
|
|
120
|
+
closes #241)
|
|
121
|
+
|
|
122
|
+
### Changed
|
|
123
|
+
|
|
124
|
+
- The agent runs with `--output-format stream-json` (the only output that
|
|
125
|
+
carries the usage reading); `agent.output_format` is read and ignored
|
|
126
|
+
(#254)
|
|
127
|
+
- A repository whose history was replaced upstream is re-cloned, unless a
|
|
128
|
+
worktree still uses the clone (finish or clean up the run first); an issue
|
|
129
|
+
that failed before the agent started is retried on the next polls, up to
|
|
130
|
+
three tries, then left as seen like any other failed run, with a chat
|
|
131
|
+
line saying so (#244, closes #234)
|
|
132
|
+
|
|
133
|
+
### Fixed
|
|
134
|
+
|
|
135
|
+
- A dev run whose PR went BEHIND mid-run no longer dies fetching its own
|
|
136
|
+
checked-out branch, and a late failure names the PR it opened (#255,
|
|
137
|
+
closes #253)
|
|
138
|
+
|
|
139
|
+
### Known limits
|
|
140
|
+
|
|
141
|
+
- With several machines, chat and status-page names are only as unique as
|
|
142
|
+
each machine's `node_id`; the duplicate-name warning does not catch every
|
|
143
|
+
case
|
|
144
|
+
- The relay's own git calls in a worktree after the agent exits still read
|
|
145
|
+
that repository's config; only the push ignores it (#263)
|
|
146
|
+
|
|
10
147
|
## [0.15.0] - 2026-10-31
|
|
11
148
|
|
|
12
149
|
The dev and task agents no longer inherit the usual GitHub credentials;
|
|
@@ -1839,7 +1976,9 @@ pipeline).
|
|
|
1839
1976
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
1840
1977
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
1841
1978
|
|
|
1842
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.
|
|
1979
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.1...HEAD
|
|
1980
|
+
[0.16.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.0...v0.16.1
|
|
1981
|
+
[0.16.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.15.0...v0.16.0
|
|
1843
1982
|
[0.15.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.14.0...v0.15.0
|
|
1844
1983
|
[0.14.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.13.0...v0.14.0
|
|
1845
1984
|
[0.13.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.4...v0.13.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.1
|
|
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/
|
|
@@ -128,11 +128,11 @@ are on the roadmap (see [Roadmap](#roadmap)).
|
|
|
128
128
|
new issue │ │ blocked session
|
|
129
129
|
│ │
|
|
130
130
|
┌──────────▼──┐ ┌─▼───────────────────┐
|
|
131
|
-
│ dev pipeline│ │
|
|
132
|
-
│ in worktree │ │ (socket ↔
|
|
131
|
+
│ dev pipeline│ │ chat bridge │
|
|
132
|
+
│ in worktree │ │ (socket ↔ chat API) │
|
|
133
133
|
│ agent CLI │ └──────────┬──────────┘
|
|
134
134
|
└──────┬──────┘ │
|
|
135
|
-
│ PR opened │
|
|
135
|
+
│ PR opened │ asks you
|
|
136
136
|
▼ ▼
|
|
137
137
|
┌────────────┐ ┌─────────┐
|
|
138
138
|
│ PR watcher │ │ You │
|
|
@@ -207,7 +207,7 @@ Run as a supervised daemon (launchd on macOS / systemd on Linux) — see
|
|
|
207
207
|
|
|
208
208
|
- [Getting started][docs-start]
|
|
209
209
|
- [Configuration][docs-config]
|
|
210
|
-
- [Telegram
|
|
210
|
+
- [Chat bridge (Telegram or Mattermost)][docs-bridge]
|
|
211
211
|
- [Feedback loop][docs-feedback]
|
|
212
212
|
- [CLI reference][docs-cli]
|
|
213
213
|
- [Operations (launchd / systemd / scheduled jobs)][ops-docs]
|
|
@@ -87,11 +87,11 @@ are on the roadmap (see [Roadmap](#roadmap)).
|
|
|
87
87
|
new issue │ │ blocked session
|
|
88
88
|
│ │
|
|
89
89
|
┌──────────▼──┐ ┌─▼───────────────────┐
|
|
90
|
-
│ dev pipeline│ │
|
|
91
|
-
│ in worktree │ │ (socket ↔
|
|
90
|
+
│ dev pipeline│ │ chat bridge │
|
|
91
|
+
│ in worktree │ │ (socket ↔ chat API) │
|
|
92
92
|
│ agent CLI │ └──────────┬──────────┘
|
|
93
93
|
└──────┬──────┘ │
|
|
94
|
-
│ PR opened │
|
|
94
|
+
│ PR opened │ asks you
|
|
95
95
|
▼ ▼
|
|
96
96
|
┌────────────┐ ┌─────────┐
|
|
97
97
|
│ PR watcher │ │ You │
|
|
@@ -166,7 +166,7 @@ Run as a supervised daemon (launchd on macOS / systemd on Linux) — see
|
|
|
166
166
|
|
|
167
167
|
- [Getting started][docs-start]
|
|
168
168
|
- [Configuration][docs-config]
|
|
169
|
-
- [Telegram
|
|
169
|
+
- [Chat bridge (Telegram or Mattermost)][docs-bridge]
|
|
170
170
|
- [Feedback loop][docs-feedback]
|
|
171
171
|
- [CLI reference][docs-cli]
|
|
172
172
|
- [Operations (launchd / systemd / scheduled jobs)][ops-docs]
|
|
@@ -25,7 +25,7 @@ mitigation within **14 days** for high-severity issues.
|
|
|
25
25
|
In-scope:
|
|
26
26
|
|
|
27
27
|
- The `ctrlrelay` CLI and its pipelines (`dev`, `secops`, scheduled jobs).
|
|
28
|
-
- The
|
|
28
|
+
- The chat bridge (Telegram or Mattermost) and socket transport.
|
|
29
29
|
- Anything in `src/` and `config/`.
|
|
30
30
|
|
|
31
31
|
Out-of-scope:
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Adding a machine
|
|
3
|
+
layout: default
|
|
4
|
+
nav_order: 11
|
|
5
|
+
description: "Run ctrlrelay on a second machine, on the same GitHub account and repositories, without the two taking the same work."
|
|
6
|
+
permalink: /adding-a-machine/
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Adding a machine
|
|
10
|
+
|
|
11
|
+
Several machines can run ctrlrelay on the same GitHub account and the same
|
|
12
|
+
repositories. Each one takes issues the others have not claimed, each
|
|
13
|
+
scheduled sweep runs once across them, and any machine's status page shows
|
|
14
|
+
all of them. This page sets up a second machine, `node-b`, next to a running
|
|
15
|
+
`node-a`.
|
|
16
|
+
|
|
17
|
+
Read [Operations: several machines on one account]({{ '/operations/' | relative_url }}#several-machines-on-one-account)
|
|
18
|
+
for how the claims work.
|
|
19
|
+
|
|
20
|
+
## 1. Install
|
|
21
|
+
|
|
22
|
+
Install ctrlrelay the same way as on the first machine
|
|
23
|
+
([Getting started]({{ '/getting-started/' | relative_url }})), the **same
|
|
24
|
+
version** on every machine. A machine on a version from before the
|
|
25
|
+
multi-machine work reads every failure comment as freeing every claim and
|
|
26
|
+
posts claims with no machine name.
|
|
27
|
+
|
|
28
|
+
## 2. Its own GitHub login
|
|
29
|
+
|
|
30
|
+
Log `gh` in on this machine with its own token:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
gh auth login
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Use the same GitHub **account** as the other machines (claims only count when
|
|
37
|
+
they come from the account the relay runs as), but a **token of its own**.
|
|
38
|
+
A token copied between machines is revoked for all of them at once.
|
|
39
|
+
|
|
40
|
+
## 3. Its own agent login
|
|
41
|
+
|
|
42
|
+
Log the agent CLI in on this machine. Each machine can use its own
|
|
43
|
+
subscription; it pauses on its own when its usage nears the limit
|
|
44
|
+
(`usage.pause_at`). Two machines on one subscription share one set of usage
|
|
45
|
+
numbers and pause together, which is correct.
|
|
46
|
+
|
|
47
|
+
## 4. A config with its own `node_id`
|
|
48
|
+
|
|
49
|
+
Copy `orchestrator.yaml` from the first machine and change:
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
node_id: "node-b" # a name no other machine on this account uses
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The `node_id` is on every claim, every chat message and the status page, so
|
|
56
|
+
two machines with the same one look like one. If you leave it out, the
|
|
57
|
+
hostname is used, which is fine when hostnames differ. ctrlrelay refuses to
|
|
58
|
+
start with a placeholder such as `my-laptop`, `localhost` or `unknown`.
|
|
59
|
+
|
|
60
|
+
Keep `repos`, `schedules` and `timezone` the same on every machine: the
|
|
61
|
+
scheduled sweep is split by its scheduled time, so machines must agree on the
|
|
62
|
+
schedule.
|
|
63
|
+
|
|
64
|
+
## 5. Peers, for the status page
|
|
65
|
+
|
|
66
|
+
To see every machine on any machine's page, list the others' status pages:
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
# on node-b
|
|
70
|
+
web:
|
|
71
|
+
host: "192.168.1.21" # an address node-a can reach
|
|
72
|
+
allowed_hosts: ["node-b"] # the name node-a uses for this machine
|
|
73
|
+
fleet:
|
|
74
|
+
peers: ["http://node-a:8787"]
|
|
75
|
+
account_label: "max-2" # optional: which subscription, never an email
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
and the mirror image on `node-a`. A peer refuses a name it does not know
|
|
79
|
+
(HTTP 421, shown as "bad host header"): each machine's `web.allowed_hosts`
|
|
80
|
+
must hold the name the others use for it.
|
|
81
|
+
|
|
82
|
+
## 6. Chat
|
|
83
|
+
|
|
84
|
+
- **Mattermost:** machines can share one bot and channel. Set
|
|
85
|
+
`transport.mattermost.shared: true` on every machine. Answer a question by
|
|
86
|
+
replying to it; a message that is not a reply must start with the machine's
|
|
87
|
+
name, `node-b: ...`.
|
|
88
|
+
- **Telegram:** each machine needs **its own bot** (create one with
|
|
89
|
+
@BotFather). Telegram lets one process read a bot at a time.
|
|
90
|
+
|
|
91
|
+
See [Operations: chat with several machines]({{ '/operations/' | relative_url }}#chat-with-several-machines).
|
|
92
|
+
|
|
93
|
+
## 7. Check it
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
ctrlrelay node check
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
✓ node_id: node-b (named on claims, chat and the page)
|
|
101
|
+
✓ GitHub login: <the account>
|
|
102
|
+
✓ agent login: works; 12% of the 5-hour window
|
|
103
|
+
✓ peer http://node-a:8787: reachable, node-a
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`--no-agent` skips the agent check, which makes one tiny call on the
|
|
107
|
+
subscription. A peer that answers with this machine's own name means two
|
|
108
|
+
machines share a `node_id` (or the URL points back here).
|
|
109
|
+
|
|
110
|
+
## 8. Start it
|
|
111
|
+
|
|
112
|
+
Start the bridge, the poller and the status page as on the first machine
|
|
113
|
+
([Operations]({{ '/operations/' | relative_url }})). At startup, and while it
|
|
114
|
+
runs, the poller warns, in its log and in chat, if another process uses its
|
|
115
|
+
name: at startup, a peer whose status page answers with its name; while it
|
|
116
|
+
runs, a claim under its name that another process is refreshing, on an issue
|
|
117
|
+
it looks at. Neither is certain to catch every case, so give each machine
|
|
118
|
+
its own `node_id` rather than relying on the warning.
|
|
119
|
+
|
|
120
|
+
Then open either machine's status page: the **Machines** strip shows both.
|
|
@@ -27,7 +27,7 @@ ctrlrelay [--version] <command> ...
|
|
|
27
27
|
| `ctrlrelay status` | Show active locks and the 5 most-recent sessions. |
|
|
28
28
|
| `ctrlrelay config ...` | Configuration helpers. |
|
|
29
29
|
| `ctrlrelay skills ...` | Skill discovery / audit. |
|
|
30
|
-
| `ctrlrelay bridge ...` | Manage the
|
|
30
|
+
| `ctrlrelay bridge ...` | Manage the chat bridge daemon (Telegram or Mattermost). |
|
|
31
31
|
| `ctrlrelay run ...` | Run a pipeline interactively. |
|
|
32
32
|
| `ctrlrelay ci ...` | CI helpers used by the dev pipeline prompt. |
|
|
33
33
|
| `ctrlrelay poller ...` | Manage the issue-poller daemon. |
|
|
@@ -128,7 +128,7 @@ Lists discovered skills as a table (name, path).
|
|
|
128
128
|
|
|
129
129
|
## `ctrlrelay bridge`
|
|
130
130
|
|
|
131
|
-
See [
|
|
131
|
+
See [Chat bridge]({{ '/bridge/' | relative_url }}) for the full setup walkthrough.
|
|
132
132
|
|
|
133
133
|
### `bridge start`
|
|
134
134
|
|
|
@@ -19,7 +19,7 @@ pydantic schema in
|
|
|
19
19
|
|
|
20
20
|
```yaml
|
|
21
21
|
version: "1"
|
|
22
|
-
node_id: "
|
|
22
|
+
node_id: "node-a"
|
|
23
23
|
timezone: "America/New_York"
|
|
24
24
|
|
|
25
25
|
paths: { ... }
|
|
@@ -33,13 +33,14 @@ repos: [ ... ]
|
|
|
33
33
|
| Key | Type | Required | Default | Description |
|
|
34
34
|
|---|---|---|---|---|
|
|
35
35
|
| `version` | string | no | `"1"` | Config schema version. Currently always `"1"`. |
|
|
36
|
-
| `node_id` | string | no | `socket.gethostname()` | Free-form identifier for this machine. Surfaces in dashboard heartbeats and
|
|
36
|
+
| `node_id` | string | no | `socket.gethostname()` | Free-form identifier for this machine. Surfaces in dashboard heartbeats, session logs, and the claim comment on every issue it takes, so machines sharing one account must each have a different one. `unknown` is reserved (it names claims from older versions). Defaults to the OS hostname when omitted, null, or blank — set explicitly only if the hostname is meaningless (CI runners, ephemeral containers). |
|
|
37
37
|
| `timezone` | string | no | `"UTC"` | IANA timezone (e.g. `America/Santiago`). Used for scheduling. |
|
|
38
38
|
| `paths` | object | **yes** | — | See [paths](#paths). |
|
|
39
39
|
| `claude` | object | no | (defaults) | See [claude](#claude). |
|
|
40
40
|
| `transport` | object | **yes** | — | See [transport](#transport). |
|
|
41
41
|
| `dashboard` | object | no | (defaults) | See [dashboard](#dashboard). |
|
|
42
42
|
| `web` | object | no | (defaults) | The read-only status page. See [web](#web). |
|
|
43
|
+
| `claims` | object | no | (defaults) | Claim timings for several machines on one account. See [claims](#claims). |
|
|
43
44
|
| `repos` | list | no | `[]` | See [repos](#repos). |
|
|
44
45
|
| `schedules` | object | no | (defaults) | See [schedules](#schedules). |
|
|
45
46
|
| `personalization` | object | no | unset | Cross-machine sync of Claude state. See [personalization](#personalization) and [Personalization sync]({{ '/personalization/' | relative_url }}). |
|
|
@@ -84,7 +85,7 @@ claude:
|
|
|
84
85
|
|---|---|---|---|
|
|
85
86
|
| `binary` | string | `"claude"` | Path to the `claude` executable. The bare name `"claude"` is auto-resolved at startup using `shutil.which("claude")`, then `~/.local/bin/claude`, `/usr/local/bin/claude`, `/opt/homebrew/bin/claude`. Set an absolute path to skip lookup (useful under launchd/systemd where PATH is minimal). |
|
|
86
87
|
| `default_timeout_seconds` | int | `1800` | Per-session timeout passed to `asyncio.wait_for`. Sessions that exceed this are killed and reported as failed. |
|
|
87
|
-
| `output_format` | string | `"json"` |
|
|
88
|
+
| `output_format` | string | `"json"` | Not used. The relay always runs `claude -p --output-format stream-json --verbose`, because only that output carries the subscription usage it reads (see [usage](#usage)). Kept so existing configs still load. |
|
|
88
89
|
|
|
89
90
|
ctrlrelay always invokes claude with `--dangerously-skip-permissions` so the
|
|
90
91
|
agent does not pause on tool-permission prompts in headless runs. This is
|
|
@@ -146,20 +147,118 @@ push. The push itself runs from a fresh relay-owned git dir under
|
|
|
146
147
|
`~/.ctrlrelay/push/`, reads the clone's branch heads from its ref files, and
|
|
147
148
|
never reads the clone's config, which every worktree shares.
|
|
148
149
|
|
|
149
|
-
**What it does not guarantee.** The agent still runs as
|
|
150
|
-
deliberately hostile agent can read
|
|
151
|
-
anything else in your home directory
|
|
152
|
-
anywhere your user can, including your
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
150
|
+
**What it does not guarantee without a sandbox.** The agent still runs as
|
|
151
|
+
your user. With `sandbox: "none"` a deliberately hostile agent can read
|
|
152
|
+
`~/.config/gh/hosts.yml`, `~/.ssh` and anything else in your home directory
|
|
153
|
+
and use them by hand, and it can write anywhere your user can, including your
|
|
154
|
+
dotfiles and ctrlrelay's install. On macOS the login keychain is reachable
|
|
155
|
+
whatever the environment says. `sandbox: "bwrap"` closes this on Linux.
|
|
156
|
+
|
|
157
|
+
**`sandbox: "bwrap"` (#236).** The dev and task agents run under bubblewrap
|
|
158
|
+
in a read-only copy of the machine (the secops agent, which needs your GitHub
|
|
159
|
+
login, does not):
|
|
160
|
+
|
|
161
|
+
- everything is read-only, so the toolchain you installed (in `/usr`, in
|
|
162
|
+
`~/.local`, `~/.cargo`, ...) still runs;
|
|
163
|
+
- writable: the run's worktree, its repository's git directories (so it can
|
|
164
|
+
commit), a private `/tmp`, the run's own empty `gh` and runtime
|
|
165
|
+
directories, `sandbox_writable_paths` and `sandbox_cache_dir`. The agent
|
|
166
|
+
CLI's state directory is opened entry by entry (`~/.claude/*`): what is in
|
|
167
|
+
it can be written, but nothing can be added to it, removed or replaced, so
|
|
168
|
+
a `settings.json` that is a link elsewhere cannot be swapped for a file of
|
|
169
|
+
the agent's. Log the agent CLI in on the machine before turning the
|
|
170
|
+
sandbox on, so its files exist. The CLI's main config, `~/.claude.json`,
|
|
171
|
+
which also holds your MCP server commands, is a private copy for each run
|
|
172
|
+
(the agent can read it, so keep secrets out of `mcpServers[].env`). The
|
|
173
|
+
sandbox refuses to start when `CLAUDE_CONFIG_DIR` is set, since it knows
|
|
174
|
+
only the default state paths;
|
|
175
|
+
- `sandbox_hide_paths` are empty, also inside a writable path: your SSH keys,
|
|
176
|
+
`gh`'s login, the other credentials listed below, the sockets in your
|
|
177
|
+
session's runtime directory and Docker's (a read-only mount does not stop
|
|
178
|
+
a connection to a socket, so add any other socket here), and the agent
|
|
179
|
+
CLI's shell snapshots, session environment files, IDE locks and rewind
|
|
180
|
+
snapshots, which your own sessions run or write back (the sandboxed run
|
|
181
|
+
gets an empty, writable one of each);
|
|
182
|
+
- read-only again, over those: `sandbox_readonly_paths` (the agent CLI's
|
|
183
|
+
settings, rules, hooks, plugins, skills, browser host and install, and each
|
|
184
|
+
project's memory under `~/.claude/projects/*/memory`, which your own
|
|
185
|
+
sessions load) and, in the repository, `config`, `config.worktree`,
|
|
186
|
+
`hooks`, `modules`, the other runs' worktree directories, a linked
|
|
187
|
+
worktree's `commondir` and `gitdir`, and the worktree's `.git` pointer,
|
|
188
|
+
which every later git command there reads. So the agent cannot change git
|
|
189
|
+
config (`git config`, `git remote add`, a branch with `--track`), and a
|
|
190
|
+
global `commit.gpgsign = true` makes its commits fail, since your GPG
|
|
191
|
+
agent is hidden. Submodules cannot be updated or committed in from a
|
|
192
|
+
sandboxed run. One that does not exist yet inside a
|
|
193
|
+
writable path is created first so the agent cannot create it (`{}` for a
|
|
194
|
+
JSON file; an empty file for a Markdown file or git's `config`,
|
|
195
|
+
`config.worktree`, `commondir` and `gitdir`; otherwise an empty directory); if it cannot be created, the run does
|
|
196
|
+
not start. Every directory between a writable path and one of these is
|
|
197
|
+
pinned too, so it cannot be renamed away and recreated.
|
|
198
|
+
|
|
199
|
+
Package caches (`XDG_CACHE_HOME`, `PIP_CACHE_DIR`, `UV_CACHE_DIR`,
|
|
200
|
+
`npm_config_cache`, `GOMODCACHE`, `GRADLE_USER_HOME` and `CARGO_HOME`) point at
|
|
201
|
+
`sandbox_cache_dir`: the agent's own, never yours, so a run cannot poison a
|
|
202
|
+
cache you build from. It must not contain your home directory. The first run
|
|
203
|
+
of each project downloads its packages again. `CARGO_HOME` and `GRADLE_USER_HOME` also
|
|
204
|
+
move those tools' own config, so `~/.cargo/config.toml` and
|
|
205
|
+
`~/.gradle/gradle.properties` do not apply there. A tool that writes
|
|
206
|
+
elsewhere in your home fails there; point it at a directory of its own
|
|
207
|
+
rather than opening one of yours, which your own builds would then run.
|
|
208
|
+
|
|
209
|
+
What it still allows: the agent reads the agent CLI's login (it needs it to
|
|
210
|
+
run) and anything readable that is not hidden. The login file is opened as a
|
|
211
|
+
file, so a refresh that replaces it by renaming fails inside the sandbox; a
|
|
212
|
+
long-lived token from `claude setup-token`, in the environment, avoids
|
|
213
|
+
refreshes there. It writes objects and refs in its repository's git
|
|
214
|
+
directory, which the relay's push already treats as untrusted, and session
|
|
215
|
+
transcripts under `~/.claude/projects`, which a later `--resume` of that
|
|
216
|
+
session reads. A read-only path that is a link, in a directory the agent
|
|
217
|
+
can write, could be swapped for a file of the agent's, so a run with one
|
|
218
|
+
does not start; the error names it.
|
|
219
|
+
`~/.claude/*` opens every entry not listed as read-only or hidden, so an
|
|
220
|
+
entry a later agent CLI version adds that your own sessions run needs adding
|
|
221
|
+
to one of those lists. The run's checkpoint file must be inside its worktree (it
|
|
222
|
+
always is for dev and task runs). It does not account for your own setup
|
|
223
|
+
giving another way in: a hidden file with a second hard link elsewhere, your
|
|
224
|
+
home mounted at a second path, or an `include.path` in a clone's git config
|
|
225
|
+
pointing at a file the agent can write. ctrlrelay's own clones have none of
|
|
226
|
+
these.
|
|
227
|
+
|
|
228
|
+
**Setting it up.** Linux only. Install bubblewrap (`sudo apt install
|
|
229
|
+
bubblewrap`), then check it runs as your user:
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
bwrap --ro-bind / / --dev /dev --proc /proc --unshare-pid true && echo works
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
If that prints `works`, set `agent.sandbox: "bwrap"` and restart the daemons.
|
|
236
|
+
ctrlrelay refuses to start if it is set and `bwrap` is missing. If it fails
|
|
237
|
+
with `setting up uid map: Permission denied` (Ubuntu 24.04 and later restrict
|
|
238
|
+
unprivileged user namespaces through AppArmor), allow it for `bwrap` only:
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
sudo tee /etc/apparmor.d/bwrap >/dev/null <<'PROFILE'
|
|
242
|
+
abi <abi/4.0>,
|
|
243
|
+
include <tunables/global>
|
|
244
|
+
|
|
245
|
+
profile bwrap /usr/bin/bwrap flags=(unconfined) {
|
|
246
|
+
userns,
|
|
247
|
+
include if exists <local/bwrap>
|
|
248
|
+
}
|
|
249
|
+
PROFILE
|
|
250
|
+
sudo apparmor_parser -r /etc/apparmor.d/bwrap
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
then run the check again. On a single-user laptop this is the whole setup.
|
|
158
254
|
|
|
159
255
|
| Key | Type | Default | Description |
|
|
160
256
|
|---|---|---|---|
|
|
161
|
-
| `sandbox` | `"none"` or `"bwrap"` | `"none"` | `"bwrap"` runs the dev and task agents
|
|
162
|
-
| `sandbox_hide_paths` | list of strings | `~/.ssh`, `~/.config/gh`, `~/.git-credentials`, `~/.config/git/credentials`, `~/.netrc`, `~/.local/share/keyrings`, `$XDG_RUNTIME_DIR/bus` |
|
|
257
|
+
| `sandbox` | `"none"` or `"bwrap"` | `"none"` | `"bwrap"` runs the dev and task agents in the read-only sandbox above. |
|
|
258
|
+
| `sandbox_hide_paths` | list of strings | `~/.ssh`, `~/.config/gh`, `~/.git-credentials`, `~/.config/git/credentials`, `~/.netrc`, `~/.local/share/keyrings`, `$XDG_RUNTIME_DIR/bus`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.config/gcloud`, `~/.kube`, `~/.docker/config.json`, `~/.npmrc`, `~/.pypirc`, `~/.cargo/credentials`, `~/.cargo/credentials.toml`, `~/.config/doctl`, `~/.password-store`, `~/.1password`, `~/.docker/run`, `~/.docker/desktop`, `~/.config/ctrlrelay`, `$XDG_RUNTIME_DIR`, `/run/user/<uid>`, `/run/docker.sock`, `/var/run/docker.sock`, `~/.claude/shell-snapshots`, `~/.claude/session-env`, `~/.claude/ide`, `~/.claude/file-history` | Made empty under `sandbox: bwrap`. A directory becomes an empty tmpfs, a file or socket becomes `/dev/null`, a missing path is skipped. |
|
|
259
|
+
| `sandbox_writable_paths` | list of strings | `~/.claude/*` | Writable under `sandbox: bwrap`, besides the run's own paths. A last part of `*` opens each entry in the directory now (not links), not the directory itself. |
|
|
260
|
+
| `sandbox_readonly_paths` | list of strings | `~/.claude/settings.json`, `settings.local.json`, `CLAUDE.md`, `agents`, `commands`, `skills`, `hooks`, `plugins`, `output-styles`, `scripts`, `rules`, `chrome`, `local`, `projects/*/memory` (all under `~/.claude`) | Read-only again inside a writable path. One `*` part matches the directories there at the start of each run; a missing path under a writable one is created first (an empty `memory` directory in each project, for one). |
|
|
261
|
+
| `sandbox_cache_dir` | string | `~/.ctrlrelay/agent-cache` | The agent's own package cache under `sandbox: bwrap`. |
|
|
163
262
|
|
|
164
263
|
## transport
|
|
165
264
|
|
|
@@ -203,7 +302,7 @@ transport:
|
|
|
203
302
|
| Key | Type | Required | Default | Description |
|
|
204
303
|
|---|---|---|---|---|
|
|
205
304
|
| `type` | enum | no | `"file_mock"` | One of `"telegram"`, `"mattermost"`, `"file_mock"`. |
|
|
206
|
-
| `telegram` | object | required when `type=telegram` | — | Telegram
|
|
305
|
+
| `telegram` | object | required when `type=telegram` | — | Telegram settings — see below. |
|
|
207
306
|
| `mattermost` | object | required when `type=mattermost` | — | Mattermost bridge settings — see below. |
|
|
208
307
|
| `file_mock` | object | required when `type=file_mock` | — | Local-file fake transport, used for tests/dev. |
|
|
209
308
|
|
|
@@ -222,7 +321,7 @@ reach anybody.
|
|
|
222
321
|
| `question_ttl_seconds` | int | `172800` (48h) | How long an **unanswered** question stays routable before it is retired. Minimum `3600`. Without a ceiling those rows never die — a question whose PR was merged by hand weeks ago stays a live target for an orphan reply. The hourly `question_expiry_sweeper` also retires a question early once every issue/PR it cites is closed or merged. |
|
|
223
322
|
| `question_retention_days` | int | `90` | How long a **finished** question (expired, or answered and resumed) is kept before the hourly sweeper deletes it. Minimum `1`. Open questions are never deleted. The kept history is what lets a re-asked question be marked as a repeat. |
|
|
224
323
|
|
|
225
|
-
See [
|
|
324
|
+
See [Chat bridge]({{ '/bridge/' | relative_url }}) for the full setup walkthrough.
|
|
226
325
|
|
|
227
326
|
### transport.mattermost
|
|
228
327
|
|
|
@@ -235,6 +334,7 @@ See [Telegram bridge]({{ '/bridge/' | relative_url }}) for the full setup walkth
|
|
|
235
334
|
| `ask_timeout_seconds` | int | `900` | Same meaning as the Telegram key above: it bounds only the in-session wait, and a later reply still resolves through `pending_resumes`. |
|
|
236
335
|
| `question_ttl_seconds` | int | `172800` (48h) | Same meaning as the Telegram key above. |
|
|
237
336
|
| `question_retention_days` | int | `90` | Same meaning as the Telegram key above. |
|
|
337
|
+
| `shared` | bool | `false` | Other machines use this same bot and channel (see [Operations: chat with several machines]({{ '/operations/' | relative_url }}#chat-with-several-machines)). Each machine then acts only on replies to its own questions and on messages that name it (`node-a: ...`); a message that names no machine is left alone. |
|
|
238
338
|
|
|
239
339
|
**The bot must be a member of that channel.** A bot without `post:all` cannot
|
|
240
340
|
post where it is not a member — and, the part that is easy to miss, it does not
|
|
@@ -308,6 +408,94 @@ web:
|
|
|
308
408
|
The page has no login and shows repository names, issue titles and the
|
|
309
409
|
questions agents asked. **Never expose it to the internet.**
|
|
310
410
|
|
|
411
|
+
## claims
|
|
412
|
+
|
|
413
|
+
How machines that share one GitHub account keep off each other's issues.
|
|
414
|
+
See [Operations: several machines on one account]({{ '/operations/' | relative_url }}#several-machines-on-one-account).
|
|
415
|
+
|
|
416
|
+
```yaml
|
|
417
|
+
claims:
|
|
418
|
+
race_wait_seconds: 5
|
|
419
|
+
refresh_seconds: 300
|
|
420
|
+
stale_after_seconds: 1800
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
| Key | Type | Default | Description |
|
|
424
|
+
|---|---|---|---|
|
|
425
|
+
| `race_wait_seconds` | number (0-60) | `5` | Wait between posting a claim and reading the comments back to see who won. |
|
|
426
|
+
| `refresh_seconds` | number (>= 30) | `300` | How often a working machine edits its claim to show it is alive. |
|
|
427
|
+
| `stale_after_seconds` | number (>= 300) | `1800` | A working claim not refreshed for this long is treated as a stopped machine's, and another machine may take the issue. Must be at least three times `refresh_seconds`. |
|
|
428
|
+
|
|
429
|
+
## usage
|
|
430
|
+
|
|
431
|
+
Pausing before the agent subscription's limit. Each run reports the
|
|
432
|
+
subscription's 5-hour and 7-day usage; the status page shows it, and a
|
|
433
|
+
machine stops taking new issues near the limit.
|
|
434
|
+
|
|
435
|
+
```yaml
|
|
436
|
+
usage:
|
|
437
|
+
pause_at: 0.9
|
|
438
|
+
probe_minutes: 30
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
| Key | Type | Default | Description |
|
|
442
|
+
|---|---|---|---|
|
|
443
|
+
| `pause_at` | number (above 0, up to 1) | `0.9` | Take no new issues while the 5-hour or 7-day window is at or above this share, or while the status is anything but `allowed`. Runs already going finish. New issues are taken again after the window's reset time; when the reset time cannot be read, after an hour, when the next reading decides again. |
|
|
444
|
+
| `probe_minutes` | int (>= 0) | `30` | When the last reading, or the last attempt that found none, is older than this, the daemon makes one tiny agent call (no tools, no MCP servers, no user or project settings and so no hooks, one turn, in an empty directory) to read the usage again, so an idle machine's numbers do not go stale. `0` turns it off. Each probe is one short call on the subscription. |
|
|
445
|
+
|
|
446
|
+
The usage comes from a field of the agent CLI's output that is not
|
|
447
|
+
documented. When it is missing or cannot be read, the page says
|
|
448
|
+
**unknown**, never 0%, and the machine does not pause on it. A run whose output carries no readable usage records unknown too, so an older number stops pausing the machine.
|
|
449
|
+
|
|
450
|
+
## github_api
|
|
451
|
+
|
|
452
|
+
Watching the account's GitHub API budget. Every machine and session on the
|
|
453
|
+
account spends the same budget per bucket (`core`, `search`,
|
|
454
|
+
`graphql`).
|
|
455
|
+
|
|
456
|
+
```yaml
|
|
457
|
+
github_api:
|
|
458
|
+
low_at: 0.2
|
|
459
|
+
check_minutes: 5
|
|
460
|
+
slow_factor: 3
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
| Key | Type | Default | Description |
|
|
464
|
+
|---|---|---|---|
|
|
465
|
+
| `low_at` | number (between 0 and 1) | `0.2` | A bucket below this share of its limit is low: the poll interval is multiplied by `slow_factor`, the status page skips its waiting-line refresh, and one chat line goes out. |
|
|
466
|
+
| `check_minutes` | int dividing 60 (1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30, 60) | `5` | How often the daemon reads `/rate_limit`. That call does not count against the budget. |
|
|
467
|
+
| `slow_factor` | number (1-20) | `3` | The poll interval multiplier while any bucket is low. |
|
|
468
|
+
|
|
469
|
+
When a bucket is used up, the daemon makes no call it knows spends that
|
|
470
|
+
bucket until its reset (`gh api` calls and `gh search`; other `gh` commands do
|
|
471
|
+
not say which bucket they spend and are not held back): such a call fails at
|
|
472
|
+
once as rate limited, one chat line says when it
|
|
473
|
+
resets, and the poller picks up again on its own afterwards. A budget it
|
|
474
|
+
could not read is shown as unknown and slows nothing.
|
|
475
|
+
|
|
476
|
+
## fleet
|
|
477
|
+
|
|
478
|
+
The other machines this one's status page shows (see
|
|
479
|
+
[Operations: several machines on one account]({{ '/operations/' | relative_url }}#several-machines-on-one-account)).
|
|
480
|
+
|
|
481
|
+
```yaml
|
|
482
|
+
fleet:
|
|
483
|
+
peers:
|
|
484
|
+
- "http://node-b:8787"
|
|
485
|
+
check_seconds: 60
|
|
486
|
+
timeout_seconds: 5
|
|
487
|
+
silent_after_seconds: 300
|
|
488
|
+
account_label: "max-2"
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
| Key | Type | Default | Description |
|
|
492
|
+
|---|---|---|---|
|
|
493
|
+
| `peers` | list of URLs | `[]` | Each peer's status page. The web server reads each one's `/api/status` itself, so the browser needs no access to them. **Each peer must accept the name this machine uses for it**: add that name to the peer's `web.allowed_hosts`, or it answers HTTP 421 and shows here as "not reachable from here: bad host header". Its `web.host` must also listen on an address this machine can reach. |
|
|
494
|
+
| `check_seconds` | int (>= 10) | `60` | How often each peer is read. |
|
|
495
|
+
| `timeout_seconds` | number (0-60] | `5` | The longest wait for any one network step of a read; a whole read gets twice this before the peer is reported as `timeout`. |
|
|
496
|
+
| `silent_after_seconds` | int (>= 60) | `300` | A machine whose daemon heartbeat is older than this is shown as **silent**: its web server answers, but the daemon's heartbeat is missing or old, so its poller cannot be confirmed to be working. |
|
|
497
|
+
| `account_label` | string | unset | A name for this machine's agent subscription, shown on its card, such as `max-2`. Never an email address (refused). |
|
|
498
|
+
|
|
311
499
|
## repos
|
|
312
500
|
|
|
313
501
|
A list of repositories the orchestrator manages.
|
|
@@ -606,7 +794,7 @@ matches Claude Code's own path encoding rule (`/Users/foo/Projects/bar`
|
|
|
606
794
|
|
|
607
795
|
```yaml
|
|
608
796
|
version: "1"
|
|
609
|
-
node_id: "
|
|
797
|
+
node_id: "node-b"
|
|
610
798
|
timezone: "America/Santiago"
|
|
611
799
|
|
|
612
800
|
paths:
|