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.
Files changed (188) hide show
  1. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/CHANGELOG.md +140 -1
  2. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/PKG-INFO +5 -5
  3. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/README.md +4 -4
  4. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/SECURITY.md +1 -1
  5. ctrlrelay-0.16.1/docs/adding-a-machine.md +120 -0
  6. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/cli.md +2 -2
  7. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/configuration.md +205 -17
  8. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/development.md +2 -2
  9. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/getting-started.md +4 -4
  10. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/index.md +2 -2
  11. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/operations.md +227 -1
  12. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/pyproject.toml +1 -1
  13. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/__main__.py +8 -0
  14. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/mattermost_handler.py +13 -0
  15. ctrlrelay-0.16.1/src/ctrlrelay/bridge/node_tag.py +50 -0
  16. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/server.py +110 -4
  17. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/telegram_handler.py +52 -1
  18. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/cli.py +496 -4
  19. ctrlrelay-0.16.1/src/ctrlrelay/core/agent_env.py +528 -0
  20. ctrlrelay-0.16.1/src/ctrlrelay/core/claims.py +628 -0
  21. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/config.py +155 -6
  22. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/dispatcher.py +196 -24
  23. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/github.py +142 -1
  24. ctrlrelay-0.16.1/src/ctrlrelay/core/github_budget.py +416 -0
  25. ctrlrelay-0.16.1/src/ctrlrelay/core/heartbeat.py +99 -0
  26. ctrlrelay-0.16.1/src/ctrlrelay/core/job_claims.py +154 -0
  27. ctrlrelay-0.16.1/src/ctrlrelay/core/node_check.py +217 -0
  28. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/poller.py +112 -3
  29. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/scheduler.py +57 -0
  30. ctrlrelay-0.16.1/src/ctrlrelay/core/usage.py +465 -0
  31. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/worktree.py +150 -23
  32. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/pipelines/dev.py +128 -12
  33. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/pipelines/secops.py +37 -0
  34. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/pipelines/task.py +75 -2
  35. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/systemd/bridge.service.template +1 -1
  36. ctrlrelay-0.16.1/src/ctrlrelay/web/fleet.py +515 -0
  37. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/server.py +80 -4
  38. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/static/index.html +288 -8
  39. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/status.py +71 -2
  40. ctrlrelay-0.16.1/tests/fixtures/stream_json_rate_limit.jsonl +4 -0
  41. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_agent_no_keys.py +4 -17
  42. ctrlrelay-0.16.1/tests/test_agent_sandbox.py +500 -0
  43. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_bridge_server.py +38 -16
  44. ctrlrelay-0.16.1/tests/test_bridge_start_command.py +63 -0
  45. ctrlrelay-0.16.1/tests/test_chat_machines.py +407 -0
  46. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_claim_reconciliation.py +39 -1
  47. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_config.py +2 -2
  48. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_dev_integration.py +4 -0
  49. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_dev_pipeline.py +15 -0
  50. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_dispatch_outcome_hook.py +17 -16
  51. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_dispatcher.py +19 -19
  52. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_event_emission.py +11 -0
  53. ctrlrelay-0.16.1/tests/test_fleet.py +549 -0
  54. ctrlrelay-0.16.1/tests/test_github_budget.py +584 -0
  55. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_install.py +11 -0
  56. ctrlrelay-0.16.1/tests/test_job_claims.py +385 -0
  57. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_mattermost_handler.py +1 -1
  58. ctrlrelay-0.16.1/tests/test_node_check.py +394 -0
  59. ctrlrelay-0.16.1/tests/test_node_claims.py +1031 -0
  60. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_obs.py +4 -0
  61. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_pipeline_base.py +1 -0
  62. ctrlrelay-0.16.1/tests/test_pre_dispatch_retry.py +257 -0
  63. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_question_backoff.py +2 -0
  64. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_relay_alert_off_lock.py +1 -1
  65. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_secops_integration.py +2 -1
  66. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_secops_pipeline.py +21 -0
  67. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_task_pipeline.py +2 -0
  68. ctrlrelay-0.16.1/tests/test_usage.py +627 -0
  69. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_web_server.py +64 -3
  70. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_web_status.py +48 -2
  71. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_worktree.py +379 -18
  72. ctrlrelay-0.15.0/src/ctrlrelay/core/agent_env.py +0 -228
  73. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  74. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  75. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  76. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/dependabot.yml +0 -0
  77. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/workflows/build.yml +0 -0
  78. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/workflows/cla.yml +0 -0
  79. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/workflows/pages.yml +0 -0
  80. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/workflows/publish.yml +0 -0
  81. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.github/workflows/test.yml +0 -0
  82. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/.gitignore +0 -0
  83. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/CODE_OF_CONDUCT.md +0 -0
  84. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/CONTRIBUTING.md +0 -0
  85. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/LICENSE +0 -0
  86. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/config/orchestrator.yaml.example +0 -0
  87. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/Gemfile +0 -0
  88. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/_config.yml +0 -0
  89. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/architecture.md +0 -0
  90. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/bridge.md +0 -0
  91. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/feedback-loop.md +0 -0
  92. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/personalization.md +0 -0
  93. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  94. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  95. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/__init__.py +0 -0
  96. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/__init__.py +0 -0
  97. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/factory.py +0 -0
  98. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/handler.py +0 -0
  99. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/bridge/protocol.py +0 -0
  100. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/__init__.py +0 -0
  101. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/archived.py +0 -0
  102. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/audit.py +0 -0
  103. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/binaries.py +0 -0
  104. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/checkpoint.py +0 -0
  105. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/code_review.py +0 -0
  106. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/dispatch_health.py +0 -0
  107. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/events.py +0 -0
  108. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/network.py +0 -0
  109. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/obs.py +0 -0
  110. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
  111. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
  112. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/publish.py +0 -0
  113. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/question_expiry.py +0 -0
  114. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/redact.py +0 -0
  115. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/core/state.py +0 -0
  116. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
  117. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/dashboard/client.py +0 -0
  118. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/gh_protocol.py +0 -0
  119. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/install.py +0 -0
  120. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/personalization/__init__.py +0 -0
  121. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/personalization/manager.py +0 -0
  122. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/personalization/paths.py +0 -0
  123. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
  124. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/pipelines/base.py +0 -0
  125. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  126. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/setup.py +0 -0
  127. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/__init__.py +0 -0
  128. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  129. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  130. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  131. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  132. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  133. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  134. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  135. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/transports/__init__.py +0 -0
  136. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/transports/base.py +0 -0
  137. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/transports/file_mock.py +0 -0
  138. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/transports/socket_client.py +0 -0
  139. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/__init__.py +0 -0
  140. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/health.py +0 -0
  141. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/links.py +0 -0
  142. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/transcript.py +0 -0
  143. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/src/ctrlrelay/web/waiting.py +0 -0
  144. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/__init__.py +0 -0
  145. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/_publish_fakes.py +0 -0
  146. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/conftest.py +0 -0
  147. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/fixtures/gh_search_issues.json +0 -0
  148. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/fixtures/transcript_sample.jsonl +0 -0
  149. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_archived.py +0 -0
  150. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_audit.py +0 -0
  151. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_binary_resolution.py +0 -0
  152. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_bridge_protocol.py +0 -0
  153. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_checkpoint.py +0 -0
  154. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_cli_ci_wait.py +0 -0
  155. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_cli_dev.py +0 -0
  156. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_cli_repos.py +0 -0
  157. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_cli_secops.py +0 -0
  158. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_cli_start.py +0 -0
  159. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_cli_version.py +0 -0
  160. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_code_review.py +0 -0
  161. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_dashboard_client.py +0 -0
  162. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_dispatch_health.py +0 -0
  163. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_docs_site.py +0 -0
  164. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_events.py +0 -0
  165. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_gh_protocol.py +0 -0
  166. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_github.py +0 -0
  167. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_issue_outcome_announcement.py +0 -0
  168. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_network.py +0 -0
  169. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_notify_transport.py +0 -0
  170. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_personalization.py +0 -0
  171. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_poller.py +0 -0
  172. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_post_merge.py +0 -0
  173. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_pr_verifier.py +0 -0
  174. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_pr_watcher.py +0 -0
  175. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_question_expiry.py +0 -0
  176. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_redact.py +0 -0
  177. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_resume_prompt_contract.py +0 -0
  178. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_scheduler.py +0 -0
  179. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_setup.py +0 -0
  180. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_state.py +0 -0
  181. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_telegram_handler.py +0 -0
  182. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_transport.py +0 -0
  183. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_transport_resolution.py +0 -0
  184. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_unreviewed_pr_announcement.py +0 -0
  185. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_web_health.py +0 -0
  186. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_web_links.py +0 -0
  187. {ctrlrelay-0.15.0 → ctrlrelay-0.16.1}/tests/test_web_transcript.py +0 -0
  188. {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.15.0...HEAD
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.15.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│ │ Telegram bridge │
132
- │ in worktree │ │ (socket ↔ bot API) │
131
+ │ dev pipeline│ │ chat bridge │
132
+ │ in worktree │ │ (socket ↔ chat API) │
133
133
  │ agent CLI │ └──────────┬──────────┘
134
134
  └──────┬──────┘ │
135
- │ PR opened │ DM you
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 bridge][docs-bridge]
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│ │ Telegram bridge │
91
- │ in worktree │ │ (socket ↔ bot API) │
90
+ │ dev pipeline│ │ chat bridge │
91
+ │ in worktree │ │ (socket ↔ chat API) │
92
92
  │ agent CLI │ └──────────┬──────────┘
93
93
  └──────┬──────┘ │
94
- │ PR opened │ DM you
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 bridge][docs-bridge]
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 Telegram bridge and socket transport.
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 Telegram bridge daemon. |
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 [Telegram bridge]({{ '/bridge/' | relative_url }}) for the full setup walkthrough.
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: "my-laptop"
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 session logs. Defaults to the OS hostname when omitted, null, or blank — set explicitly only if the hostname is meaningless (CI runners, ephemeral containers). |
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"` | Forwarded as `--output-format` to `claude -p`. |
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 your user. A
150
- deliberately hostile agent can read `~/.config/gh/hosts.yml`, `~/.ssh` and
151
- anything else in your home directory and use them by hand; it can write
152
- anywhere your user can, including your dotfiles and ctrlrelay's install; and
153
- it can write to `~/.ctrlrelay/push/`, the bare clone's object store and any
154
- `objects/info/alternates` chain it adds there, racing a push. On macOS the
155
- login keychain is reachable whatever the environment says. Closing that needs
156
- a separate user or a read-only home (#236). The next setting hides the
157
- credential paths; it does not stop the writes.
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 under bubblewrap with `sandbox_hide_paths` replaced by empty mounts. Linux only; needs `bwrap` installed and unprivileged user namespaces allowed (on Ubuntu 24.04+ an AppArmor profile for `bwrap`). ctrlrelay refuses to start if it is set and `bwrap` is missing. |
162
- | `sandbox_hide_paths` | list of strings | `~/.ssh`, `~/.config/gh`, `~/.git-credentials`, `~/.config/git/credentials`, `~/.netrc`, `~/.local/share/keyrings`, `$XDG_RUNTIME_DIR/bus` | Paths hidden under `sandbox: bwrap`. A directory becomes an empty tmpfs, a file or socket becomes `/dev/null`, a missing path is skipped. |
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 bridge settings — see below. |
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 [Telegram bridge]({{ '/bridge/' | relative_url }}) for the full setup walkthrough.
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: "studio-mac"
797
+ node_id: "node-b"
610
798
  timezone: "America/Santiago"
611
799
 
612
800
  paths: