ctrlrelay 0.24.0__tar.gz → 0.26.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/CHANGELOG.md +52 -1
  2. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/cli.md +2 -1
  4. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/configuration.md +7 -5
  5. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/operations.md +81 -0
  6. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/pyproject.toml +1 -1
  7. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/cli.py +150 -52
  8. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/agent_env.py +43 -3
  9. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/config.py +5 -0
  10. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/dispatcher.py +47 -9
  11. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/orphans.py +34 -4
  12. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/poller.py +202 -0
  13. ctrlrelay-0.26.0/src/ctrlrelay/core/slots.py +96 -0
  14. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/state.py +96 -1
  15. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/worktree.py +72 -1
  16. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/pipelines/dev.py +586 -19
  17. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/pipelines/secops.py +468 -437
  18. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/fleet.py +10 -5
  19. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/server.py +2 -0
  20. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/static/index.html +15 -3
  21. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/status.py +1 -0
  22. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_agent_sandbox.py +198 -2
  23. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_fleet.py +15 -1
  24. ctrlrelay-0.26.0/tests/test_max_parallel.py +416 -0
  25. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_no_change_run.py +141 -2
  26. ctrlrelay-0.26.0/tests/test_run_resume.py +660 -0
  27. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_drift.py +35 -6
  28. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  29. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  30. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  31. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/dependabot.yml +0 -0
  32. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/workflows/build.yml +0 -0
  33. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/workflows/cla.yml +0 -0
  34. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/workflows/pages.yml +0 -0
  35. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/workflows/publish.yml +0 -0
  36. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.github/workflows/test.yml +0 -0
  37. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/.gitignore +0 -0
  38. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/CODE_OF_CONDUCT.md +0 -0
  39. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/CONTRIBUTING.md +0 -0
  40. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/LICENSE +0 -0
  41. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/README.md +0 -0
  42. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/SECURITY.md +0 -0
  43. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/config/orchestrator.yaml.example +0 -0
  44. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/Gemfile +0 -0
  45. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/_config.yml +0 -0
  46. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/adding-a-machine.md +0 -0
  47. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/architecture.md +0 -0
  48. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/bridge.md +0 -0
  49. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/development.md +0 -0
  50. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/feedback-loop.md +0 -0
  51. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/getting-started.md +0 -0
  52. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/index.md +0 -0
  53. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/llms.txt +0 -0
  54. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/personalization.md +0 -0
  55. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/registered-mode.md +0 -0
  56. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  57. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  58. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/__init__.py +0 -0
  59. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  60. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  61. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/factory.py +0 -0
  62. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/handler.py +0 -0
  63. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  64. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
  65. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  66. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/server.py +0 -0
  67. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  68. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/__init__.py +0 -0
  69. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/archived.py +0 -0
  70. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/audit.py +0 -0
  71. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/binaries.py +0 -0
  72. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/blockers.py +0 -0
  73. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/budget.py +0 -0
  74. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  75. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/claims.py +0 -0
  76. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/code_review.py +0 -0
  77. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
  78. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/events.py +0 -0
  79. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/github.py +0 -0
  80. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/github_budget.py +0 -0
  81. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/heartbeat.py +0 -0
  82. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/intake_hold.py +0 -0
  83. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/issue_questions.py +0 -0
  84. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/job_claims.py +0 -0
  85. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/network.py +0 -0
  86. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/node_check.py +0 -0
  87. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/obs.py +0 -0
  88. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  89. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  90. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/proof.py +0 -0
  91. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/publish.py +0 -0
  92. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  93. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/redact.py +0 -0
  94. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/reopens.py +0 -0
  95. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/scheduler.py +0 -0
  96. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/self_update.py +0 -0
  97. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/stuck.py +0 -0
  98. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/usage.py +0 -0
  99. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/core/weekly_summary.py +0 -0
  100. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  101. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/dashboard/client.py +0 -0
  102. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/gh_protocol.py +0 -0
  103. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/install.py +0 -0
  104. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  105. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/personalization/manager.py +0 -0
  106. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/personalization/merge.py +0 -0
  107. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/personalization/paths.py +0 -0
  108. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  109. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/pipelines/base.py +0 -0
  110. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  111. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/pipelines/task.py +0 -0
  112. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/setup.py +0 -0
  113. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/__init__.py +0 -0
  114. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  115. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  116. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  117. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  118. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  119. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  120. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  121. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  122. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/transports/__init__.py +0 -0
  123. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/transports/base.py +0 -0
  124. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  125. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  126. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/__init__.py +0 -0
  127. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/drift.py +0 -0
  128. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/health.py +0 -0
  129. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/links.py +0 -0
  130. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/numbers.py +0 -0
  131. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/transcript.py +0 -0
  132. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/src/ctrlrelay/web/waiting.py +0 -0
  133. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/__init__.py +0 -0
  134. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/_poller_fakes.py +0 -0
  135. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/_publish_fakes.py +0 -0
  136. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/conftest.py +0 -0
  137. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/fixtures/gh_search_issues.json +0 -0
  138. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  139. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/fixtures/transcript_sample.jsonl +0 -0
  140. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_agent_backend_wiring.py +0 -0
  141. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_agent_no_keys.py +0 -0
  142. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_agent_routing.py +0 -0
  143. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_archived.py +0 -0
  144. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_audit.py +0 -0
  145. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_binary_resolution.py +0 -0
  146. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_bridge_protocol.py +0 -0
  147. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_bridge_server.py +0 -0
  148. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_bridge_start_command.py +0 -0
  149. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_budget.py +0 -0
  150. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_chat_machines.py +0 -0
  151. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_checkpoint.py +0 -0
  152. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_claim_reconciliation.py +0 -0
  153. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_cli_ci_wait.py +0 -0
  154. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_cli_dev.py +0 -0
  155. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_cli_repos.py +0 -0
  156. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_cli_secops.py +0 -0
  157. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_cli_start.py +0 -0
  158. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_cli_version.py +0 -0
  159. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_code_review.py +0 -0
  160. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_config.py +0 -0
  161. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dashboard_client.py +0 -0
  162. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dev_integration.py +0 -0
  163. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dev_pipeline.py +0 -0
  164. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dev_plan_step.py +0 -0
  165. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dev_step_switches.py +0 -0
  166. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dev_stuck.py +0 -0
  167. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dev_work_steps.py +0 -0
  168. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dispatch_health.py +0 -0
  169. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dispatch_outcome_hook.py +0 -0
  170. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_dispatcher.py +0 -0
  171. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_docs_site.py +0 -0
  172. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_event_emission.py +0 -0
  173. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_events.py +0 -0
  174. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_gh_protocol.py +0 -0
  175. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_github.py +0 -0
  176. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_github_budget.py +0 -0
  177. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_install.py +0 -0
  178. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_intake_hold.py +0 -0
  179. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_intake_hold_poller.py +0 -0
  180. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_issue_blockers.py +0 -0
  181. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_issue_outcome_announcement.py +0 -0
  182. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_issue_questions.py +0 -0
  183. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_job_claims.py +0 -0
  184. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_mattermost_handler.py +0 -0
  185. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_network.py +0 -0
  186. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_node_check.py +0 -0
  187. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_node_claims.py +0 -0
  188. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_notify_transport.py +0 -0
  189. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_obs.py +0 -0
  190. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_opencode_adapter.py +0 -0
  191. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_orphans.py +0 -0
  192. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_personalization.py +0 -0
  193. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_personalization_merge.py +0 -0
  194. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_pipeline_base.py +0 -0
  195. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_poller.py +0 -0
  196. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_poller_priority_order.py +0 -0
  197. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_poller_recheck.py +0 -0
  198. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_poller_search.py +0 -0
  199. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_post_merge.py +0 -0
  200. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_pr_verifier.py +0 -0
  201. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_pr_watcher.py +0 -0
  202. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_pre_dispatch_retry.py +0 -0
  203. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_proof.py +0 -0
  204. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_publish_failure_retry.py +0 -0
  205. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_question_backoff.py +0 -0
  206. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_question_expiry.py +0 -0
  207. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_question_survives_restart.py +0 -0
  208. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_redact.py +0 -0
  209. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_relay_alert_off_lock.py +0 -0
  210. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_relay_git_263.py +0 -0
  211. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_reopens.py +0 -0
  212. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_resume_prompt_contract.py +0 -0
  213. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_review_copy.py +0 -0
  214. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_review_gate.py +0 -0
  215. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_sandbox_visibility.py +0 -0
  216. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_scheduler.py +0 -0
  217. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_secops_integration.py +0 -0
  218. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_secops_operator_prs.py +0 -0
  219. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_secops_pipeline.py +0 -0
  220. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_self_update.py +0 -0
  221. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_setup.py +0 -0
  222. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_state.py +0 -0
  223. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_stuck.py +0 -0
  224. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_task_pipeline.py +0 -0
  225. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_telegram_handler.py +0 -0
  226. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_timeout_save.py +0 -0
  227. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_transport.py +0 -0
  228. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_transport_resolution.py +0 -0
  229. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_unreviewed_pr_announcement.py +0 -0
  230. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_usage.py +0 -0
  231. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_health.py +0 -0
  232. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_links.py +0 -0
  233. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_numbers.py +0 -0
  234. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_peer_links.py +0 -0
  235. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_server.py +0 -0
  236. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_status.py +0 -0
  237. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_transcript.py +0 -0
  238. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_web_waiting.py +0 -0
  239. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_weekly_summary.py +0 -0
  240. {ctrlrelay-0.24.0 → ctrlrelay-0.26.0}/tests/test_worktree.py +0 -0
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.26.0] - 2026-11-06
11
+
12
+ ### Added
13
+
14
+ - A dev run can now finish with an answer instead of code (#368). For an
15
+ issue that asks a question, an investigation, a plan or a proposed split,
16
+ the agent commits nothing and writes its answer as `"reply"` in the DONE
17
+ block, and the relay posts it on the issue as a comment, in full (cut at
18
+ 60,000 characters). A document that belongs in the repository is still
19
+ committed and opened as a pull request. Replies follow
20
+ `question_on_issue`: a public repository gets a short note and the reply
21
+ goes to chat, unless the repository is set to `full`.
22
+
23
+ ### Changed
24
+
25
+ - The status page says `5-hour none (ultra plan)` instead of
26
+ `5-hour unknown` when a usage reading has a 7-day window and no 5-hour
27
+ one, which is how ultra plans report (#367).
28
+
29
+ ## [0.25.0] - 2026-11-06
30
+
31
+ ### Fixed
32
+
33
+ - A sandboxed run (`agent.sandbox: bwrap`) failed at once with "Argument
34
+ list too long" on a machine with thousands of past agent project folders
35
+ (#362). The sandbox mounted each `~/.claude/projects/*/memory` on its own,
36
+ which ran past both the kernel's argument limit and bwrap's 9000 arguments.
37
+ It now mounts `~/.claude/projects` read-only once, opens only the run's
38
+ own transcript folder for writing, and passes its options to bwrap
39
+ through a file descriptor. A config that lists the old glob in
40
+ `sandbox_readonly_paths` should drop it.
41
+
42
+ ### Added
43
+
44
+ - `agent.max_parallel` lets one machine run several agents at once (#210).
45
+ It is one cap over new issues, resumed answers and the security sweep.
46
+ Each poll's issues start side by side up to the free slots, in priority
47
+ order, never two of one repo at once, and the loop polls again while they
48
+ run. Unset keeps the old one-at-a-time behaviour. The status page's slot
49
+ count now shows the real number of runs and the cap, or "no cap".
50
+ - A dev run saves each step it finishes (plan, code, publish, verify) in a
51
+ new `run_steps` table, and a run whose process died is continued after
52
+ its last saved step instead of starting over (#231). The branch must
53
+ still be at the saved commit. The agent is not run again for a saved
54
+ code step, and a saved pull request is reused rather than opened twice.
55
+ Proof and review always run again. `ctrlrelay run dev --from-step
56
+ plan|code|publish` starts a new run from a past step, keeping the old
57
+ branch head under `refs/ctrlrelay/before-retry/`.
58
+
10
59
  ## [0.24.0] - 2026-11-05
11
60
 
12
61
  ### Added
@@ -2591,7 +2640,9 @@ pipeline).
2591
2640
  per-phase implementation plans (Phase 0 through Phase 4).
2592
2641
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
2593
2642
 
2594
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.24.0...HEAD
2643
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.26.0...HEAD
2644
+ [0.26.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.25.0...v0.26.0
2645
+ [0.25.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.24.0...v0.25.0
2595
2646
  [0.24.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.23.0...v0.24.0
2596
2647
  [0.23.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.22.0...v0.23.0
2597
2648
  [0.22.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.21.0...v0.22.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.24.0
3
+ Version: 0.26.0
4
4
  Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
5
5
  Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
6
6
  Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
@@ -192,7 +192,7 @@ without waiting for a real pipeline run.
192
192
  ### `run dev`
193
193
 
194
194
  ```bash
195
- ctrlrelay run dev -i ISSUE [-r REPO] [-c PATH]
195
+ ctrlrelay run dev -i ISSUE [-r REPO] [-c PATH] [--from-step STEP]
196
196
  ```
197
197
 
198
198
  | Flag | Required | Default | Description |
@@ -200,6 +200,7 @@ ctrlrelay run dev -i ISSUE [-r REPO] [-c PATH]
200
200
  | `--issue` / `-i` | yes | — | GitHub issue number. |
201
201
  | `--repo` / `-r` | when more than one repo is configured | — | Limit to a single `owner/repo`. |
202
202
  | `--config` / `-c` | no | `config/orchestrator.yaml` | Config path. |
203
+ | `--from-step` | no | — | Start again from `plan`, `code` or `publish`, taking the earlier steps from the issue's newest run that saved them and moving the branch back. See [A run whose process died](operations.md#a-run-whose-process-died). |
203
204
 
204
205
  Acquires the per-repo lock, creates the worktree, spawns the repo's agent
205
206
  (Claude Code or OpenCode, per `agent.type` / `repos[].agent_type`) with the
@@ -209,6 +209,7 @@ agent:
209
209
  | `type` | string | `"claude"` | Default backend for every repo: `"claude"` or `"opencode"`. See [agent backends](#agent-backends). |
210
210
  | `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). |
211
211
  | `default_timeout_seconds` | int | `1800` | Per-session timeout passed to `asyncio.wait_for`. Sessions that exceed this are killed and reported as failed. A session going in circles is stopped before this; see [stuck runs](operations.md#stuck-runs). |
212
+ | `max_parallel` | int (at least 1) | unset | How many agent runs this machine may have going at once, counted over new issues, resumed answers and the security sweep together. With it set, each poll's issues start side by side up to the free slots, in priority order, and never two of one repo at once; the poll loop polls again while they run. Unset keeps the old behaviour: new issues one at a time, and no shared limit. The status page shows `slots busy/max_parallel`, or `slots N (no cap)` when unset. See [running several agents at once](operations.md#running-several-agents-at-once). |
212
213
  | `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. |
213
214
 
214
215
  ctrlrelay always invokes claude with `--dangerously-skip-permissions` so the
@@ -361,9 +362,10 @@ login, does not):
361
362
  snapshots, which your own sessions run or write back (the sandboxed run
362
363
  gets an empty, writable one of each);
363
364
  - read-only again, over those: `sandbox_readonly_paths` (the agent CLI's
364
- settings, rules, hooks, plugins, skills, browser host and install, and each
365
- project's memory under `~/.claude/projects/*/memory`, which your own
366
- sessions load) and, in the repository, `config`, `config.worktree`,
365
+ settings, rules, hooks, plugins, skills, browser host and install, and the
366
+ whole `~/.claude/projects` folder with every project's memory, which your
367
+ own sessions load; only the run's own transcript folder in it is writable)
368
+ and, in the repository, `config`, `config.worktree`,
367
369
  `hooks`, `modules`, the other runs' worktree directories, a linked
368
370
  worktree's `commondir` and `gitdir`, and the worktree's `.git` pointer,
369
371
  which every later git command there reads. So the agent cannot change git
@@ -438,7 +440,7 @@ then run the check again. On a single-user laptop this is the whole setup.
438
440
  | `sandbox` | `"none"` or `"bwrap"` | `"none"` | `"bwrap"` runs the dev and task agents in the read-only sandbox above. |
439
441
  | `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. |
440
442
  | `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. |
441
- | `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). |
443
+ | `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` (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. The run's own transcript folder under `projects` is made writable again by the relay. Before 0.25.0 the default had `projects/*/memory`, one mount per project folder; on a home with thousands of them bwrap could not start (#362). A config that still lists that glob has the same problem, so drop it. |
442
444
  | `sandbox_cache_dir` | string | `~/.ctrlrelay/agent-cache` | The agent's own package cache under `sandbox: bwrap`. |
443
445
 
444
446
  ## transport
@@ -764,7 +766,7 @@ repos:
764
766
  | `code_review` | object | no | (defaults) | Review run over an agent branch before its PR is handed over. See [code_review](#code_review). |
765
767
  | `steps` | object | no | (all unset) | How strict each dev pipeline work step is. See [steps](#repossteps). |
766
768
  | `max_fix_attempts` | integer ≥ 0 | no | `3` | Fix rounds the dev pipeline asks for when a PR's CI is red or its branch conflicts, before the run fails. `0` means none. See [steps](#repossteps). |
767
- | `question_on_issue` | `auto` \| `full` \| `notice` | no | `auto` | What a dev run's question looks like on its GitHub issue. The question is asked there as well as in chat, and the first answer from either wins; on the issue only a comment that starts with `/answer`, from an account with write access, counts. `auto` posts the full question on a private repository and a short "a question is waiting for the operator in chat" notice on a public one (or one whose visibility could not be read); a chat answer is copied to the issue under the same rule. `full` and `notice` force one or the other. |
769
+ | `question_on_issue` | `auto` \| `full` \| `notice` | no | `auto` | What a dev run's question looks like on its GitHub issue. The question is asked there as well as in chat, and the first answer from either wins; on the issue only a comment that starts with `/answer`, from an account with write access, counts. `auto` posts the full question on a private repository and a short "a question is waiting for the operator in chat" notice on a public one (or one whose visibility could not be read); a chat answer is copied to the issue under the same rule. `full` and `notice` force one or the other. A run that finishes with a reply instead of code (#368) follows the same rule: the reply is posted on the issue when the question would be, and otherwise the issue gets a short note and the reply goes to chat. |
768
770
  | `deploy` | object | no | `null` | Reserved for deploy policy. Currently surfaced in `ctrlrelay config repos` but otherwise inert. |
769
771
 
770
772
  ### repos[].automation
@@ -781,6 +781,87 @@ limit until one is set.
781
781
  read. A run on `opencode` is not measured; with a limit set, the log gets
782
782
  `dispatcher.budget.unmeasured` and nothing is enforced.
783
783
 
784
+ ## A run whose process died
785
+
786
+ A dev run saves each step it finishes: `plan`, `code`, `publish` and
787
+ `verify` (#231). Each is a row in the `run_steps` table of the state DB,
788
+ with the run branch's commit at that moment and what the later steps need
789
+ (the plan, the agent's result, the pull request). No commit is added to the
790
+ branch for it.
791
+
792
+ When the relay process dies in the middle of a run (out of memory, a
793
+ restart, a power cut), the poller settles that run when it starts again and
794
+ hands the issue back. The next run for the issue then continues after the
795
+ last saved step:
796
+
797
+ - it checks that the branch is still at the saved commit, and starts from
798
+ the beginning if not (or stops with the reason, when the dead run had
799
+ already opened its pull request);
800
+ - it does not run those steps again: no new agent session for a saved code
801
+ step, no second push or pull request for a saved publish step;
802
+ - it comments on the issue which run it continues and from where;
803
+ - `proof` and `review` always run again, because they read the pull
804
+ request as it is now.
805
+
806
+ A dead run that had opened its pull request is handed back too, and only
807
+ that pull request is reused. A run that failed for its own reasons is not
808
+ continued; the next run starts from the beginning, as before.
809
+
810
+ To start again from a past step by hand, for example to redo the code with
811
+ the same plan:
812
+
813
+ ```bash
814
+ ctrlrelay run dev --issue 42 --repo your-org/your-repo --from-step code
815
+ ```
816
+
817
+ `--from-step` takes `plan`, `code` or `publish`, uses the newest run of the
818
+ issue that saved steps, and moves the branch back to where it was before
819
+ that step. The old head is kept at `refs/ctrlrelay/before-retry/<run id>`
820
+ in the relay's clone. It refuses a branch behind an open pull request, and a
821
+ branch that is on GitHub is synced back to GitHub's head; delete the remote
822
+ branch first in that case.
823
+
824
+ The log events are `dev.step.saved`, `dev.resume.restored` and
825
+ `dev.resume.head_moved`.
826
+
827
+ ## Running several agents at once
828
+
829
+ Set `agent.max_parallel` to let one machine run more than one agent (#210):
830
+
831
+ ```yaml
832
+ agent:
833
+ max_parallel: 3
834
+ ```
835
+
836
+ - **One cap over everything.** New issues from the poll, runs resumed after
837
+ you answer a question, and the scheduled security sweep all take a slot
838
+ from the same pool. A run takes its slot before the repo lock, and gives
839
+ it back when it ends.
840
+ - **New issues side by side.** Polled issues wait in a backlog, in priority
841
+ order, and start as slots free up. Two issues of the same repo never run
842
+ together: the second waits, and an issue of another repo behind it goes
843
+ first. The loop keeps polling on its interval whatever is running, so one
844
+ slow issue never holds up new ones.
845
+ - **A run holds its slot for its whole life**, including the wait for CI
846
+ and the fix rounds, and while it waits in the run for your answer. Once
847
+ it gives up waiting and saves the question, the slot is free again, and
848
+ the resume takes one when your answer comes. Two pull requests waiting on
849
+ slow CI can fill `max_parallel: 2`.
850
+ - **A hold placed while a run waited for its slot is respected.** The resume
851
+ sweeper and the security sweep check again once they have a slot, so a
852
+ `self-update` that started meanwhile is not raced.
853
+ - **On shutdown** no new run starts in a slot a stopping run frees, and
854
+ issues polled but not started are offered again after the restart.
855
+ - **Unset** keeps the old behaviour: new issues one at a time, and nothing
856
+ limits the total.
857
+
858
+ Each run uses its own agent session, worktree and memory. Size the number to
859
+ the machine and to your plan's usage limits. Start with 2.
860
+
861
+ The status page shows `slots busy/max`, and `slots N (no cap)` when it is
862
+ unset. The log event `poll.batch.parallel` names each batch, the limit and
863
+ how many slots were busy.
864
+
784
865
  ## When to restart
785
866
 
786
867
  | You changed... | Restart... |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.24.0"
3
+ version = "0.26.0"
4
4
  description = "Local-first orchestrator for headless coding agents across multiple GitHub repos"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -545,6 +545,11 @@ def unresolved_review_programs(config) -> dict[str, list[str]]:
545
545
  }
546
546
 
547
547
 
548
+ # A chat message is capped by the transport (Telegram: 4,096 characters);
549
+ # the reply is cut well short of that, leaving room for the first line.
550
+ _CHAT_REPLY_MAX = 3_000
551
+
552
+
548
553
  async def announce_dev_success(
549
554
  transport, *, repo: str, result
550
555
  ) -> list[str]:
@@ -575,6 +580,16 @@ async def announce_dev_success(
575
580
  f"✅ {repo}: finished, no change needed: "
576
581
  f"{outputs.get('no_change_reason') or 'no reason given'}"
577
582
  )
583
+ reply = outputs.get("no_change_reply")
584
+ if isinstance(reply, str) and reply:
585
+ # #368: posted on the issue, or held back from a public one,
586
+ # in which case chat is the only place it lands.
587
+ if outputs.get("no_change_reply_posted") is True:
588
+ line += "\nThe agent's reply is posted on the issue."
589
+ else:
590
+ cut = reply[:_CHAT_REPLY_MAX]
591
+ more = "\n(cut here)" if len(reply) > _CHAT_REPLY_MAX else ""
592
+ line += f"\nThe agent's reply (not posted on the issue):\n{cut}{more}"
578
593
  await transport.send(line)
579
594
  sent.append(line)
580
595
  return sent
@@ -2503,6 +2518,16 @@ def run_dev(
2503
2518
  "-c",
2504
2519
  help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
2505
2520
  ),
2521
+ from_step: str | None = typer.Option(
2522
+ None,
2523
+ "--from-step",
2524
+ help=(
2525
+ "Start again from a past step of the issue's newest run that "
2526
+ "saved steps: plan, code or publish. The steps before it are "
2527
+ "taken from that run and the branch is moved back to where "
2528
+ "it was then."
2529
+ ),
2530
+ ),
2506
2531
  ) -> None:
2507
2532
  """Run dev pipeline for a GitHub issue."""
2508
2533
  import asyncio
@@ -2598,6 +2623,7 @@ def run_dev(
2598
2623
  steps=repo_config.steps,
2599
2624
  question_on_issue=repo_config.question_on_issue,
2600
2625
  require_proof=repo_config.automation.require_proof,
2626
+ from_step=from_step,
2601
2627
  )
2602
2628
 
2603
2629
  try:
@@ -3727,6 +3753,12 @@ def poller_start(
3727
3753
  github, config.node_id,
3728
3754
  window=sweep_window(config.schedules.secops_cron, config.timezone),
3729
3755
  ),
3756
+ slots=agent_slots,
3757
+ # Asked again per repo once it has a slot (#210).
3758
+ held=lambda: (
3759
+ None if (h := intake_hold.active()) is None
3760
+ else h.reason
3761
+ ),
3730
3762
  )
3731
3763
  ok = sum(1 for r in results if r.success)
3732
3764
  console.print(
@@ -3894,6 +3926,35 @@ def poller_start(
3894
3926
  except Exception:
3895
3927
  pass
3896
3928
 
3929
+ # The node's cap on agent runs (#210), shared by the poll loop, the
3930
+ # resume sweeper and the security sweep. No limit when unset.
3931
+ from ctrlrelay.core.slots import AgentSlots
3932
+ agent_slots = AgentSlots(config.agent.max_parallel)
3933
+
3934
+ def _resume_no_longer_wanted(row: dict) -> str | None:
3935
+ """Why an answered question should not be resumed right now,
3936
+ read after the wait for a slot (#210), or None to go ahead. A
3937
+ hold placed meanwhile (a self-update) leaves the row for a
3938
+ later tick; a row resumed, expired or answered again meanwhile
3939
+ is not this tick's to run."""
3940
+ hold = intake_hold.active()
3941
+ if hold is not None:
3942
+ return f"new work is held ({hold.reason})"
3943
+ try:
3944
+ current = state_db.get_pending_resume(row["session_id"])
3945
+ except Exception as e:
3946
+ # Could not look: leave the answer for the next tick rather
3947
+ # than letting the sweeper's error path mark it resumed.
3948
+ return f"the question could not be read ({type(e).__name__})"
3949
+ if (
3950
+ current is None
3951
+ or current.get("resumed_at") is not None
3952
+ or current.get("expired_at") is not None
3953
+ or current.get("answered_at") != row.get("answered_at")
3954
+ ):
3955
+ return "the question is no longer waiting for this answer"
3956
+ return None
3957
+
3897
3958
  async def _run_pending_resume_sweeper() -> None:
3898
3959
  """Drain pending_resumes rows that an operator answered via
3899
3960
  Telegram while the original BLOCKED session had already torn
@@ -3998,28 +4059,40 @@ def poller_start(
3998
4059
  try:
3999
4060
  if pipeline_name == "secops":
4000
4061
  sweep_repo_cfg = repo_cfg_by_name.get(repo)
4001
- result = await resume_secops_from_pending(
4002
- session_id=session_id,
4003
- repo=repo,
4004
- answer=answer,
4005
- dispatcher=dispatcher,
4006
- github=github,
4007
- worktree=worktree,
4008
- dashboard=scheduled_dashboard,
4009
- state_db=state_db,
4010
- transport=sweeper_transport,
4011
- contexts_dir=config.paths.contexts,
4012
- automation=(
4013
- sweep_repo_cfg.automation
4014
- if sweep_repo_cfg is not None
4015
- else None
4016
- ),
4017
- question=row.get("question"),
4018
- # Same tracker as the poller and the
4019
- # sweep: a repo archived while a session
4020
- # sat blocked must not be resumed into.
4021
- archived=poller.archived_tracker,
4022
- )
4062
+ # A slot of the node's cap (#210), before the repo lock.
4063
+ async with agent_slots.hold():
4064
+ # The wait for a slot can be long (#210):
4065
+ # ask again whether to go ahead.
4066
+ gone = _resume_no_longer_wanted(row)
4067
+ if gone is not None:
4068
+ console.print(
4069
+ "[dim]pending_resume_sweeper: "
4070
+ f"{session_id} not resumed now: "
4071
+ f"{gone}[/dim]"
4072
+ )
4073
+ continue
4074
+ result = await resume_secops_from_pending(
4075
+ session_id=session_id,
4076
+ repo=repo,
4077
+ answer=answer,
4078
+ dispatcher=dispatcher,
4079
+ github=github,
4080
+ worktree=worktree,
4081
+ dashboard=scheduled_dashboard,
4082
+ state_db=state_db,
4083
+ transport=sweeper_transport,
4084
+ contexts_dir=config.paths.contexts,
4085
+ automation=(
4086
+ sweep_repo_cfg.automation
4087
+ if sweep_repo_cfg is not None
4088
+ else None
4089
+ ),
4090
+ question=row.get("question"),
4091
+ # Same tracker as the poller and the
4092
+ # sweep: a repo archived while a session
4093
+ # sat blocked must not be resumed into.
4094
+ archived=poller.archived_tracker,
4095
+ )
4023
4096
  elif pipeline_name == "dev":
4024
4097
  # Dev resume needs the repo's branch template
4025
4098
  # to reconstruct the session's existing branch
@@ -4043,37 +4116,61 @@ def poller_start(
4043
4116
  except Exception:
4044
4117
  pass
4045
4118
  continue
4046
- result = await resume_dev_from_pending(
4047
- session_id=session_id,
4048
- repo=repo,
4049
- answer=answer,
4050
- branch_template=repo_cfg.dev_branch_template,
4051
- dispatcher=dispatcher,
4052
- github=github,
4053
- worktree=worktree,
4054
- dashboard=scheduled_dashboard,
4055
- state_db=state_db,
4056
- transport=sweeper_transport,
4057
- contexts_dir=config.paths.contexts,
4058
- max_fix_attempts=repo_cfg.max_fix_attempts,
4059
- code_review=repo_cfg.code_review,
4060
- steps=repo_cfg.steps,
4061
- question_on_issue=repo_cfg.question_on_issue,
4062
- require_proof=repo_cfg.automation.require_proof,
4063
- )
4119
+ # A slot of the node's cap (#210), before the repo lock.
4120
+ async with agent_slots.hold():
4121
+ # The wait for a slot can be long (#210):
4122
+ # ask again whether to go ahead.
4123
+ gone = _resume_no_longer_wanted(row)
4124
+ if gone is not None:
4125
+ console.print(
4126
+ "[dim]pending_resume_sweeper: "
4127
+ f"{session_id} not resumed now: "
4128
+ f"{gone}[/dim]"
4129
+ )
4130
+ continue
4131
+ result = await resume_dev_from_pending(
4132
+ session_id=session_id,
4133
+ repo=repo,
4134
+ answer=answer,
4135
+ branch_template=repo_cfg.dev_branch_template,
4136
+ dispatcher=dispatcher,
4137
+ github=github,
4138
+ worktree=worktree,
4139
+ dashboard=scheduled_dashboard,
4140
+ state_db=state_db,
4141
+ transport=sweeper_transport,
4142
+ contexts_dir=config.paths.contexts,
4143
+ max_fix_attempts=repo_cfg.max_fix_attempts,
4144
+ code_review=repo_cfg.code_review,
4145
+ steps=repo_cfg.steps,
4146
+ question_on_issue=repo_cfg.question_on_issue,
4147
+ require_proof=repo_cfg.automation.require_proof,
4148
+ )
4064
4149
  elif pipeline_name == "task":
4065
- result = await resume_task_from_pending(
4066
- session_id=session_id,
4067
- repo=repo,
4068
- answer=answer,
4069
- dispatcher=dispatcher,
4070
- github=github,
4071
- worktree=worktree,
4072
- dashboard=scheduled_dashboard,
4073
- state_db=state_db,
4074
- transport=sweeper_transport,
4075
- contexts_dir=config.paths.contexts,
4076
- )
4150
+ # A slot of the node's cap (#210), before the repo lock.
4151
+ async with agent_slots.hold():
4152
+ # The wait for a slot can be long (#210):
4153
+ # ask again whether to go ahead.
4154
+ gone = _resume_no_longer_wanted(row)
4155
+ if gone is not None:
4156
+ console.print(
4157
+ "[dim]pending_resume_sweeper: "
4158
+ f"{session_id} not resumed now: "
4159
+ f"{gone}[/dim]"
4160
+ )
4161
+ continue
4162
+ result = await resume_task_from_pending(
4163
+ session_id=session_id,
4164
+ repo=repo,
4165
+ answer=answer,
4166
+ dispatcher=dispatcher,
4167
+ github=github,
4168
+ worktree=worktree,
4169
+ dashboard=scheduled_dashboard,
4170
+ state_db=state_db,
4171
+ transport=sweeper_transport,
4172
+ contexts_dir=config.paths.contexts,
4173
+ )
4077
4174
  else:
4078
4175
  console.print(
4079
4176
  "[yellow]pending_resume_sweeper: "
@@ -4498,6 +4595,7 @@ def poller_start(
4498
4595
  await run_poll_loop(
4499
4596
  poller=poller, handler=handle_issue_unless_held,
4500
4597
  interval=interval,
4598
+ slots=agent_slots,
4501
4599
  interval_scale=lambda: (
4502
4600
  config.github_api.slow_factor
4503
4601
  if github_budget.is_low() else 1.0
@@ -244,9 +244,12 @@ DEFAULT_SANDBOX_READONLY_PATHS: tuple[str, ...] = (
244
244
  "~/.claude/chrome",
245
245
  # The agent CLI itself, when installed under its state directory.
246
246
  "~/.claude/local",
247
- # Each project's memory, which the operator's sessions in that project
248
- # load. One ``*`` part is expanded to the directories there now.
249
- "~/.claude/projects/*/memory",
247
+ # Every project's folder, memory included, which the operator's
248
+ # sessions in that project load. One mount for all of them (#362): a
249
+ # mount per project folder ran past bwrap's argument limit on a home
250
+ # with thousands. The run's own transcript folder is opened again,
251
+ # deeper, by the dispatcher.
252
+ "~/.claude/projects",
250
253
  )
251
254
 
252
255
  # The agent's package caches: its own, never the operator's, so a run
@@ -285,6 +288,43 @@ def _existing(raw: str | Path) -> Path | None:
285
288
  return p if p.exists() else None
286
289
 
287
290
 
291
+ def bwrap_args_via_fd(prefix: list[str]) -> tuple[list[str], int | None]:
292
+ """Move a ``bwrap_prefix`` argv's options into a file descriptor (#362).
293
+
294
+ One mount is a few path arguments, and a home with thousands of agent
295
+ project folders makes thousands of them: past ``ARG_MAX`` the kernel
296
+ refuses to start bwrap at all (``E2BIG``, "Argument list too long").
297
+ bwrap reads NUL-separated options from ``--args FD``, which has no such
298
+ limit, so the command line stays ``bwrap --args FD --`` however many
299
+ mounts there are.
300
+
301
+ Returns the short argv and the descriptor, which the caller passes to
302
+ the child (``pass_fds``) and closes once it has started. An empty
303
+ prefix (no sandbox) comes back as it is, with ``None``.
304
+ """
305
+ if not prefix:
306
+ return prefix, None
307
+ if prefix[-1] != "--":
308
+ raise ValueError("not a bwrap_prefix argv: it must end with '--'")
309
+ data = b"".join(os.fsencode(a) + b"\0" for a in prefix[1:-1])
310
+ if hasattr(os, "memfd_create"):
311
+ fd = os.memfd_create("ctrlrelay-bwrap-args")
312
+ else: # pragma: no cover - bwrap is Linux-only, and Linux has memfd
313
+ import tempfile
314
+
315
+ fd, path = tempfile.mkstemp(prefix="ctrlrelay-bwrap-args-")
316
+ os.unlink(path)
317
+ try:
318
+ view = memoryview(data)
319
+ while view:
320
+ view = view[os.write(fd, view):]
321
+ os.lseek(fd, 0, os.SEEK_SET)
322
+ except BaseException:
323
+ os.close(fd)
324
+ raise
325
+ return [prefix[0], "--args", str(fd), "--"], fd
326
+
327
+
288
328
  def bwrap_prefix(
289
329
  bwrap: str,
290
330
  hide_paths: Sequence[str],
@@ -230,6 +230,11 @@ class AgentConfig(BaseModel):
230
230
  opencode: OpenCodeConfig = Field(default_factory=OpenCodeConfig)
231
231
  # #223. Off until a limit is set.
232
232
  budget: BudgetConfig = Field(default_factory=BudgetConfig)
233
+ # #210: how many agent runs this node may have going at once, over
234
+ # every track (new issues, resumed answers, the security sweep).
235
+ # Unset: no shared limit, and the poll loop takes one issue at a time,
236
+ # as before the setting existed.
237
+ max_parallel: int | None = Field(default=None, ge=1)
233
238
 
234
239
 
235
240
  # Backwards-compat alias. Older code and docs reference ``ClaudeConfig``;