ctrlrelay 0.16.1__tar.gz → 0.16.2__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 (187) hide show
  1. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/CHANGELOG.md +27 -1
  2. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/PKG-INFO +1 -1
  3. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/docs/operations.md +5 -1
  4. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/pyproject.toml +1 -1
  5. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/web/server.py +18 -1
  6. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/web/static/index.html +139 -12
  7. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/web/status.py +113 -1
  8. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_web_server.py +227 -0
  9. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  10. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  11. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  12. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/.github/dependabot.yml +0 -0
  13. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/.github/workflows/build.yml +0 -0
  14. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/.github/workflows/cla.yml +0 -0
  15. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/.github/workflows/pages.yml +0 -0
  16. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/.github/workflows/publish.yml +0 -0
  17. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/.github/workflows/test.yml +0 -0
  18. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/.gitignore +0 -0
  19. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/CODE_OF_CONDUCT.md +0 -0
  20. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/CONTRIBUTING.md +0 -0
  21. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/LICENSE +0 -0
  22. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/README.md +0 -0
  23. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/SECURITY.md +0 -0
  24. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/config/orchestrator.yaml.example +0 -0
  25. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/docs/Gemfile +0 -0
  26. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/docs/_config.yml +0 -0
  27. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/docs/adding-a-machine.md +0 -0
  28. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/docs/architecture.md +0 -0
  29. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/docs/bridge.md +0 -0
  30. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/docs/cli.md +0 -0
  31. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/docs/configuration.md +0 -0
  32. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/docs/development.md +0 -0
  33. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/docs/feedback-loop.md +0 -0
  34. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/docs/getting-started.md +0 -0
  35. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/docs/index.md +0 -0
  36. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/docs/personalization.md +0 -0
  37. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  38. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  39. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/__init__.py +0 -0
  40. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/bridge/__init__.py +0 -0
  41. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/bridge/__main__.py +0 -0
  42. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/bridge/factory.py +0 -0
  43. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/bridge/handler.py +0 -0
  44. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  45. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/bridge/node_tag.py +0 -0
  46. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/bridge/protocol.py +0 -0
  47. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/bridge/server.py +0 -0
  48. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  49. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/cli.py +0 -0
  50. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/__init__.py +0 -0
  51. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/agent_env.py +0 -0
  52. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/archived.py +0 -0
  53. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/audit.py +0 -0
  54. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/binaries.py +0 -0
  55. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/checkpoint.py +0 -0
  56. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/claims.py +0 -0
  57. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/code_review.py +0 -0
  58. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/config.py +0 -0
  59. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/dispatch_health.py +0 -0
  60. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/dispatcher.py +0 -0
  61. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/events.py +0 -0
  62. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/github.py +0 -0
  63. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/github_budget.py +0 -0
  64. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/heartbeat.py +0 -0
  65. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/job_claims.py +0 -0
  66. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/network.py +0 -0
  67. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/node_check.py +0 -0
  68. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/obs.py +0 -0
  69. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/poller.py +0 -0
  70. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/pr_verifier.py +0 -0
  71. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/pr_watcher.py +0 -0
  72. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/publish.py +0 -0
  73. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/question_expiry.py +0 -0
  74. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/redact.py +0 -0
  75. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/scheduler.py +0 -0
  76. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/state.py +0 -0
  77. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/usage.py +0 -0
  78. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/core/worktree.py +0 -0
  79. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/dashboard/__init__.py +0 -0
  80. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/dashboard/client.py +0 -0
  81. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/gh_protocol.py +0 -0
  82. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/install.py +0 -0
  83. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/personalization/__init__.py +0 -0
  84. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/personalization/manager.py +0 -0
  85. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/personalization/paths.py +0 -0
  86. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/pipelines/__init__.py +0 -0
  87. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/pipelines/base.py +0 -0
  88. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/pipelines/dev.py +0 -0
  89. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  90. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/pipelines/secops.py +0 -0
  91. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/pipelines/task.py +0 -0
  92. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/setup.py +0 -0
  93. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/templates/__init__.py +0 -0
  94. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  95. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  96. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  97. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  98. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  99. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  100. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  101. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  102. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/transports/__init__.py +0 -0
  103. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/transports/base.py +0 -0
  104. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/transports/file_mock.py +0 -0
  105. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/transports/socket_client.py +0 -0
  106. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/web/__init__.py +0 -0
  107. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/web/fleet.py +0 -0
  108. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/web/health.py +0 -0
  109. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/web/links.py +0 -0
  110. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/web/transcript.py +0 -0
  111. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/src/ctrlrelay/web/waiting.py +0 -0
  112. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/__init__.py +0 -0
  113. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/_publish_fakes.py +0 -0
  114. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/conftest.py +0 -0
  115. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/fixtures/gh_search_issues.json +0 -0
  116. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  117. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/fixtures/transcript_sample.jsonl +0 -0
  118. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_agent_no_keys.py +0 -0
  119. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_agent_sandbox.py +0 -0
  120. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_archived.py +0 -0
  121. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_audit.py +0 -0
  122. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_binary_resolution.py +0 -0
  123. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_bridge_protocol.py +0 -0
  124. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_bridge_server.py +0 -0
  125. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_bridge_start_command.py +0 -0
  126. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_chat_machines.py +0 -0
  127. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_checkpoint.py +0 -0
  128. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_claim_reconciliation.py +0 -0
  129. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_cli_ci_wait.py +0 -0
  130. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_cli_dev.py +0 -0
  131. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_cli_repos.py +0 -0
  132. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_cli_secops.py +0 -0
  133. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_cli_start.py +0 -0
  134. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_cli_version.py +0 -0
  135. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_code_review.py +0 -0
  136. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_config.py +0 -0
  137. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_dashboard_client.py +0 -0
  138. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_dev_integration.py +0 -0
  139. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_dev_pipeline.py +0 -0
  140. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_dispatch_health.py +0 -0
  141. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_dispatch_outcome_hook.py +0 -0
  142. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_dispatcher.py +0 -0
  143. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_docs_site.py +0 -0
  144. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_event_emission.py +0 -0
  145. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_events.py +0 -0
  146. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_fleet.py +0 -0
  147. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_gh_protocol.py +0 -0
  148. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_github.py +0 -0
  149. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_github_budget.py +0 -0
  150. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_install.py +0 -0
  151. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_issue_outcome_announcement.py +0 -0
  152. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_job_claims.py +0 -0
  153. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_mattermost_handler.py +0 -0
  154. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_network.py +0 -0
  155. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_node_check.py +0 -0
  156. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_node_claims.py +0 -0
  157. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_notify_transport.py +0 -0
  158. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_obs.py +0 -0
  159. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_personalization.py +0 -0
  160. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_pipeline_base.py +0 -0
  161. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_poller.py +0 -0
  162. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_post_merge.py +0 -0
  163. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_pr_verifier.py +0 -0
  164. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_pr_watcher.py +0 -0
  165. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_pre_dispatch_retry.py +0 -0
  166. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_question_backoff.py +0 -0
  167. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_question_expiry.py +0 -0
  168. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_redact.py +0 -0
  169. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_relay_alert_off_lock.py +0 -0
  170. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_resume_prompt_contract.py +0 -0
  171. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_scheduler.py +0 -0
  172. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_secops_integration.py +0 -0
  173. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_secops_pipeline.py +0 -0
  174. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_setup.py +0 -0
  175. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_state.py +0 -0
  176. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_task_pipeline.py +0 -0
  177. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_telegram_handler.py +0 -0
  178. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_transport.py +0 -0
  179. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_transport_resolution.py +0 -0
  180. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_unreviewed_pr_announcement.py +0 -0
  181. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_usage.py +0 -0
  182. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_web_health.py +0 -0
  183. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_web_links.py +0 -0
  184. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_web_status.py +0 -0
  185. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_web_transcript.py +0 -0
  186. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_web_waiting.py +0 -0
  187. {ctrlrelay-0.16.1 → ctrlrelay-0.16.2}/tests/test_worktree.py +0 -0
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.16.2] - 2026-11-01
11
+
12
+ Status page changes only. **No config edit is required.**
13
+
14
+ ### Added
15
+
16
+ - The Today counters (dispatched, PRs opened, merged, failed, blocked,
17
+ messages sent) are buttons: each opens today's events of that kind under
18
+ the counters. A failed run shows why (exit code, the run's summary and its
19
+ stored output), a blocked one the question, PRs link to the PR. New
20
+ `/api/events?kind=<kind>&day=today` (#271, closes #257)
21
+
22
+ ### Changed
23
+
24
+ - A GitHub API bucket whose reset time has passed since the last read shows
25
+ as "refilled at <time>, not read since" instead of "unknown"; search,
26
+ which refills every minute, usually shows that way (#272)
27
+ - More space under the header and between the Machines strip and "Who has
28
+ what" (#271)
29
+
30
+ ### Fixed
31
+
32
+ - The live feed now scrubs credential shapes from event details: a
33
+ question an agent asked could show a token on the page (#271)
34
+
10
35
  ## [0.16.1] - 2026-11-01
11
36
 
12
37
  Fixes the chat bridge, which did not start on 0.16.0. **No config edit is
@@ -1976,7 +2001,8 @@ pipeline).
1976
2001
  per-phase implementation plans (Phase 0 through Phase 4).
1977
2002
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1978
2003
 
1979
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.1...HEAD
2004
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.2...HEAD
2005
+ [0.16.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.1...v0.16.2
1980
2006
  [0.16.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.0...v0.16.1
1981
2007
  [0.16.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.15.0...v0.16.0
1982
2008
  [0.15.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.14.0...v0.15.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.16.1
3
+ Version: 0.16.2
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/
@@ -264,6 +264,7 @@ loopback or on the home network.
264
264
  | `/` | The page. Refreshes itself every `web.refresh_seconds`. |
265
265
  | `/api/status` | Everything on the page, as JSON (the contract is in `src/ctrlrelay/web/status.py`). A section that could not be read is `null` and `errors` says why; `[]` means "read, and empty". The feed holds at most the newest `feed_limit` (200) events: `feed_total` is how many the history window holds (`null` if unreadable) and `feed_truncated` is `true` when some were left out, in which case the page says "showing newest N of M". |
266
266
  | `/api/events?since=<epoch seconds>` | The feed, newest first, at most `limit` (200) events. Without `since`, the last `web.history_days` days. Carries the same `feed_total` and `feed_truncated`, plus the `limit` used. |
267
+ | `/api/events?kind=<kind>&day=today` | One Today counter's events, for the server's local day, newest first, at most 200: `{kind, events, total, truncated, error}`. `kind` is `dispatched`, `pr_opened`, `merged`, `failed`, `blocked` or `message_sent`. A `failed` or `blocked` event also carries `reason`: `exit_code`, the run's `summary` (up to 500 characters) and `output_tail` (up to 2,000), both scrubbed of credential shapes again before they are sent, the `question` asked, and `session: "not found"` when the run's row is gone. `events` is `null` with `error` set when they could not be read. A query with `kind` and anything other than exactly `kind` and `day=today` is refused with 400, except that one with `since` is the feed, as before. A summary or question longer than 500 characters ends in "…". On the page, clicking a Today counter shows this list under it, read again with every refresh. |
267
268
 
268
269
  The page header shows how long ago it last heard from the server ("updated
269
270
  12s ago", ticking every second) and a dot that is green while the last good
@@ -290,7 +291,10 @@ machine last read it: `read_at`, and for `core`, `search` and `graphql` each
290
291
  `{remaining, limit, resets_at, state}` with `state` one of `ok`, `low`, `out`,
291
292
  or `unknown` once its reset time has passed (the numbers are then old), or
292
293
  `"unknown"` in place of the object for a bucket it could not read; the whole value is `"unknown"`
293
- before any reading. The page shows it in the **Usage** box. Low and used-up
294
+ before any reading. The page shows it in the **Usage** box; a bucket whose
295
+ reset time has passed since the last read shows as "refilled at <time>, not
296
+ read since" (GitHub refills it at that time), and `search`, which refills
297
+ every minute, usually shows that way. Low and used-up
294
298
  buckets are described in [configuration: github_api]({{ '/configuration/' | relative_url }}#github_api).
295
299
  Which bucket a `gh` command spends is worked out from the command where the
296
300
  command says: `gh api` REST paths are `core`, `gh api graphql` is `graphql`,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.16.1"
3
+ version = "0.16.2"
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"
@@ -45,7 +45,7 @@ from ctrlrelay.core.obs import get_logger, log_event
45
45
  from ctrlrelay.core.usage import UsageStore, pause_for, usage_json
46
46
  from ctrlrelay.web.fleet import PeerReader
47
47
  from ctrlrelay.web.health import read_server_health
48
- from ctrlrelay.web.status import build_status, list_feed
48
+ from ctrlrelay.web.status import DAY_KINDS, build_status, list_day_events, list_feed
49
49
 
50
50
  _logger = get_logger("web.server")
51
51
 
@@ -206,6 +206,23 @@ class _Handler(BaseHTTPRequestHandler):
206
206
  self._json(200, srv.status())
207
207
  elif url.path == "/api/events":
208
208
  query = urllib.parse.parse_qs(url.query, keep_blank_values=True)
209
+ if "kind" in query and "since" not in query:
210
+ # One Today counter's events (#257). A query with ``since``
211
+ # is the feed, as it was before ``kind`` existed.
212
+ kinds, days = query.get("kind"), query.get("day")
213
+ if (
214
+ len(kinds) != 1 or kinds[0] not in DAY_KINDS
215
+ or days != ["today"] or set(query) != {"kind", "day"}
216
+ ):
217
+ self._json(400, {
218
+ "error": "kind must be one of " + ", ".join(sorted(DAY_KINDS))
219
+ + ", with day=today and nothing else",
220
+ })
221
+ return
222
+ self._json(200, list_day_events(
223
+ db_path=srv.config.paths.state_db, kind=kinds[0],
224
+ ))
225
+ return
209
226
  values = query.get("since")
210
227
  if values is None:
211
228
  since = srv.default_since()
@@ -90,6 +90,17 @@ ul.plain > li { padding: 7px 0; border-bottom: 1px solid var(--line); }
90
90
  ul.plain > li:last-child { border-bottom: 0; }
91
91
  .today { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
92
92
  .today .n { font-size: 22px; font-weight: 700; }
93
+ .today button.card {
94
+ font: inherit; color: inherit; text-align: left; cursor: pointer; width: 100%;
95
+ }
96
+ .today button.card:hover { border-color: var(--accent); }
97
+ .today button.card[aria-pressed="true"] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
98
+ #today-list { margin-top: 10px; }
99
+ #today-list .reason { font-size: 13px; color: var(--muted); margin: 2px 0 0; }
100
+ #today-list pre {
101
+ white-space: pre-wrap; word-break: break-word; font-size: 12px; margin: 6px 0 0;
102
+ max-height: 260px; overflow: auto; background: var(--tag); padding: 8px; border-radius: 6px;
103
+ }
93
104
  .feed .when { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; min-width: 4.5em; }
94
105
  .feed .detail { color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }
95
106
  a { color: var(--accent); text-decoration: none; }
@@ -108,8 +119,8 @@ a:hover, a:focus-visible { text-decoration: underline; }
108
119
  .usage .bar { height: 6px; background: var(--line, #ddd); border-radius: 3px; margin: 2px 0 6px; }
109
120
  .usage .bar > span { display: block; height: 100%; border-radius: 3px; background: currentColor; }
110
121
  .usage .bar.high { color: var(--bad); }
111
- .fleet { margin: 0 20px 12px; }
112
- .strip { display: flex; flex-wrap: wrap; gap: 10px; }
122
+ .fleet { margin: 18px 20px 4px; }
123
+ .strip { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 18px; }
113
124
  .strip .machine { border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px;
114
125
  min-width: 190px; background: var(--panel); font-size: 13px; }
115
126
  .strip .machine.bad { border-color: var(--bad); }
@@ -164,6 +175,7 @@ details > summary .count { font-weight: 400; margin-left: 6px; }
164
175
  <section>
165
176
  <h2>Today</h2>
166
177
  <div id="today"><p class="unknown">loading…</p></div>
178
+ <div id="today-list" aria-live="polite"></div>
167
179
  </section>
168
180
  </div>
169
181
  <div>
@@ -782,18 +794,32 @@ function renderGitHubBudget(api, now, lines) {
782
794
  }
783
795
  ["core", "search", "graphql"].forEach(function (name) {
784
796
  var b = api[name];
785
- if (!b || typeof b !== "object" || typeof b.remaining !== "number" ||
786
- b.state === "unknown") {
787
- lines.push(el("div", "line", "GitHub " + name + " unknown"));
788
- return;
789
- }
790
- var high = b.state === "low" || b.state === "out";
791
- lines.push(el("div", "line" + (high ? " high" : ""),
792
- "GitHub " + name + " " + b.remaining + " of " + b.limit + " left" +
793
- (b.state === "out" ? " (used up)" : "") + ", " + untilText(b.resets_at, now)));
797
+ var high = b && !refilled(b, now) && (b.state === "low" || b.state === "out");
798
+ lines.push(el("div", "line" + (high ? " high" : ""), budgetLine(name, b, now)));
794
799
  });
795
800
  }
796
801
 
802
+ // One bucket's line. A reading whose reset time has passed is old: GitHub
803
+ // has refilled that bucket since, so say that rather than "unknown" (search
804
+ // refills every minute and is read every few, so it is usually in this state).
805
+ function refilled(b, now) {
806
+ var reset = b && typeof b === "object" ? Date.parse(b.resets_at) : NaN;
807
+ return !isNaN(reset) && reset / 1000 <= now;
808
+ }
809
+
810
+ function budgetLine(name, b, now) {
811
+ if (!b || typeof b !== "object" || typeof b.remaining !== "number") {
812
+ return "GitHub " + name + " unknown";
813
+ }
814
+ if (refilled(b, now)) {
815
+ return "GitHub " + name + " refilled at " + clock(Date.parse(b.resets_at) / 1000) +
816
+ ", not read since";
817
+ }
818
+ if (b.state === "unknown") { return "GitHub " + name + " unknown"; }
819
+ return "GitHub " + name + " " + b.remaining + " of " + b.limit + " left" +
820
+ (b.state === "out" ? " (used up)" : "") + ", " + untilText(b.resets_at, now);
821
+ }
822
+
797
823
  function renderUsage(data, now) {
798
824
  var target = document.getElementById("usage");
799
825
  var n = data.node;
@@ -835,21 +861,121 @@ function renderUsage(data, now) {
835
861
  put(target, lines);
836
862
  }
837
863
 
864
+ // The event kind each Today counter counts (#257); the counter's own key
865
+ // differs only for messages.
866
+ var TODAY_KIND = { messages_sent: "message_sent" };
867
+ var todayOpen = null;
868
+ // Each list request is numbered; only the newest one may draw, so a slow
869
+ // answer for an earlier refresh or another counter never replaces a newer one.
870
+ var todaySeq = 0;
871
+ // The kind whose list request is still out. A refresh does not start
872
+ // another for it (on a slow server every answer would be overtaken and the
873
+ // list would stay at "loading…"); a click always does.
874
+ var todayBusy = null;
875
+ // A refresh skipped while a request was out: read again when it lands, so
876
+ // the list does not lag the count by more than one answer.
877
+ var todayStale = false;
878
+
838
879
  function renderToday(data) {
839
880
  var target = document.getElementById("today");
840
881
  var t = data.today || {};
841
882
  var grid = el("div", "today");
842
883
  TODAY.forEach(function (pair) {
843
884
  var v = t[pair[0]];
844
- var cell = el("div", "card");
885
+ var kind = TODAY_KIND[pair[0]] || pair[0];
886
+ var cell = el("button", "card");
887
+ cell.type = "button";
888
+ cell.setAttribute("aria-pressed", todayOpen === kind ? "true" : "false");
889
+ cell.title = "Show today's " + pair[1];
845
890
  if (typeof v === "number") { cell.appendChild(el("div", "n", v)); }
846
891
  else { cell.appendChild(el("div", "n unknown", "unknown")); }
847
892
  cell.appendChild(el("div", "muted", pair[1]));
893
+ cell.addEventListener("click", function () {
894
+ todayOpen = todayOpen === kind ? null : kind;
895
+ renderToday(lastData || data);
896
+ if (todayOpen) { put(document.getElementById("today-list"), [empty("loading…")]); }
897
+ loadTodayList(true);
898
+ });
848
899
  grid.appendChild(cell);
849
900
  });
850
901
  put(target, [grid]);
851
902
  }
852
903
 
904
+ // The list behind the open counter, read again with every refresh so it
905
+ // follows the count.
906
+ function loadTodayList(force) {
907
+ var target = document.getElementById("today-list");
908
+ var kind = todayOpen;
909
+ if (!kind) { todaySeq++; todayBusy = null; put(target, []); return; }
910
+ if (!force && todayBusy === kind) { todayStale = true; return; }
911
+ var seq = ++todaySeq;
912
+ todayBusy = kind;
913
+ todayStale = false;
914
+ fetch("/api/events?kind=" + encodeURIComponent(kind) + "&day=today", { cache: "no-store" })
915
+ .then(function (resp) { return resp.json(); })
916
+ .then(function (data) { if (seq === todaySeq) { renderTodayList(data); } })
917
+ .catch(function (err) {
918
+ if (seq === todaySeq) {
919
+ put(target, [unknown("unknown: the list could not be read (" +
920
+ (err && err.message ? err.message : "error") + ")")]);
921
+ }
922
+ })
923
+ .then(function () {
924
+ if (seq !== todaySeq) { return; }
925
+ todayBusy = null;
926
+ if (todayStale && todayOpen === kind) { loadTodayList(false); }
927
+ });
928
+ }
929
+
930
+ function renderTodayList(data) {
931
+ var target = document.getElementById("today-list");
932
+ if (!Array.isArray(data.events)) {
933
+ put(target, [unknown("unknown: " + (data.error || "the list could not be read"))]);
934
+ return;
935
+ }
936
+ if (data.events.length === 0) { put(target, [empty("none today")]); return; }
937
+ var list = el("ul", "plain");
938
+ data.events.forEach(function (ev) {
939
+ var li = el("li", "card");
940
+ var row = el("div", "row");
941
+ row.appendChild(el("span", "when", clock(ev.ts)));
942
+ if (ev.repo) {
943
+ row.appendChild(ghLink(ev.url,
944
+ ev.repo + (typeof ev.issue_number === "number" ? "#" + ev.issue_number : ""), "repo"));
945
+ }
946
+ if (ev.session_id) { row.appendChild(el("span", "muted", ev.session_id)); }
947
+ li.appendChild(row);
948
+ var r = ev.reason;
949
+ if (r) {
950
+ if (r.session === "not found") {
951
+ li.appendChild(el("p", "reason", "the run's record is gone"));
952
+ }
953
+ if (typeof r.exit_code === "number") {
954
+ li.appendChild(el("p", "reason", "exit code " + r.exit_code));
955
+ }
956
+ if (r.question) { li.appendChild(el("p", "reason", "asked: " + r.question)); }
957
+ if (r.summary) { li.appendChild(el("p", "reason", r.summary)); }
958
+ if (r.output_tail) {
959
+ var more = el("details");
960
+ more.appendChild(el("summary", null, "output"));
961
+ more.appendChild(el("pre", null, r.output_tail));
962
+ li.appendChild(more);
963
+ }
964
+ if (!r.question && !r.summary && !r.output_tail && typeof r.exit_code !== "number"
965
+ && r.session !== "not found") {
966
+ li.appendChild(el("p", "reason", "no reason was recorded"));
967
+ }
968
+ } else {
969
+ var text = detailText(ev.detail);
970
+ if (text) { li.appendChild(el("div", "detail", text)); }
971
+ }
972
+ list.appendChild(li);
973
+ });
974
+ var note = data.truncated === true
975
+ ? el("p", "empty", "showing newest " + data.events.length + " of " + data.total) : null;
976
+ put(target, note ? [note, list] : [list]);
977
+ }
978
+
853
979
  function detailText(detail) {
854
980
  if (detail === null || detail === undefined) { return ""; }
855
981
  if (typeof detail !== "object") { return String(detail); }
@@ -917,6 +1043,7 @@ function render(data) {
917
1043
  renderWaiting(data);
918
1044
  renderSkipped(data);
919
1045
  renderToday(data);
1046
+ loadTodayList();
920
1047
  renderFleet(data, now);
921
1048
  renderUsage(data, now);
922
1049
  renderServer(data);
@@ -87,6 +87,7 @@ import json
87
87
  import os
88
88
  import re
89
89
  import sqlite3
90
+ import time
90
91
  import urllib.parse
91
92
  from collections.abc import Callable, Mapping
92
93
  from datetime import UTC, date, datetime, timedelta
@@ -369,9 +370,23 @@ def _event_url(repo: Any, issue_number: Any, detail: Any) -> str | None:
369
370
  return github_url(repo, issue_number)
370
371
 
371
372
 
373
+ def _scrubbed(detail: Any) -> Any:
374
+ """``detail`` with credential shapes removed from its text: a question
375
+ the agent asked is stored as it wrote it, and the page shows it."""
376
+ from ctrlrelay.core.redact import redact_secrets
377
+
378
+ if isinstance(detail, dict):
379
+ return {k: _scrubbed(v) for k, v in detail.items()}
380
+ if isinstance(detail, list):
381
+ return [_scrubbed(v) for v in detail]
382
+ if isinstance(detail, str):
383
+ return redact_secrets(detail)
384
+ return detail
385
+
386
+
372
387
  def _event(row: dict[str, Any]) -> dict[str, Any]:
373
388
  ts = int(row["ts"])
374
- detail = _detail(row["detail"])
389
+ detail = _scrubbed(_detail(row["detail"]))
375
390
  return {
376
391
  "ts": ts,
377
392
  "time": _iso(ts),
@@ -792,6 +807,103 @@ def list_feed(*, db_path: Path, since_ts: int, limit: int | None = None) -> dict
792
807
  }
793
808
 
794
809
 
810
+ # The kinds a Today counter counts, which ``list_day_events`` lists (#257).
811
+ DAY_KINDS = frozenset(_TODAY)
812
+ # How much of a failed run's stored output and summary a row carries.
813
+ REASON_TAIL_CHARS = 2000
814
+ REASON_SUMMARY_CHARS = 500
815
+
816
+
817
+ def _read_day_events(
818
+ conn: sqlite3.Connection, kind: str, start: int, end: int, limit: int,
819
+ ) -> tuple[list[dict[str, Any]], int]:
820
+ rows = conn.execute(
821
+ """SELECT e.ts, e.kind, e.repo, e.issue_number, e.session_id, e.detail,
822
+ s.id AS s_id, s.claude_exit_code, s.summary, s.output_tail,
823
+ s.blocked_question
824
+ FROM events e LEFT JOIN sessions s ON s.id = e.session_id
825
+ WHERE e.kind = ? AND e.ts >= ? AND e.ts < ?
826
+ ORDER BY e.ts DESC, e.id DESC LIMIT ?""",
827
+ (kind, start, end, int(limit)),
828
+ ).fetchall()
829
+ (total,) = conn.execute(
830
+ "SELECT count(*) FROM events WHERE kind = ? AND ts >= ? AND ts < ?",
831
+ (kind, start, end),
832
+ ).fetchone()
833
+ out = []
834
+ for r in rows:
835
+ row = dict(r)
836
+ event = _event(row)
837
+ if kind in ("failed", "blocked"):
838
+ event["reason"] = _reason(row, event["detail"])
839
+ out.append(event)
840
+ return out, int(total)
841
+
842
+
843
+ def _cut(text: str, limit: int) -> str:
844
+ """``text`` up to ``limit`` characters, ending in "…" when cut."""
845
+ return text if len(text) <= limit else text[: limit - 1] + "…"
846
+
847
+
848
+ def _reason(row: dict[str, Any], detail: Any) -> dict[str, Any]:
849
+ """Why a run failed or stopped, from what the relay stored: the exit
850
+ code, its summary and output tail, and the question asked, each
851
+ scrubbed of credential shapes again here. ``session`` says when the run's row is gone."""
852
+ from ctrlrelay.core.redact import redact_secrets, tail
853
+
854
+ detail = detail if isinstance(detail, dict) else {}
855
+ exit_code = detail.get("exit_code")
856
+ if not isinstance(exit_code, int) or isinstance(exit_code, bool):
857
+ exit_code = row.get("claude_exit_code")
858
+ question = detail.get("question") or row.get("blocked_question")
859
+ summary = row.get("summary")
860
+ output = row.get("output_tail")
861
+ reason: dict[str, Any] = {
862
+ "exit_code": exit_code if isinstance(exit_code, int) else None,
863
+ "summary": (
864
+ _cut(redact_secrets(summary), REASON_SUMMARY_CHARS)
865
+ if isinstance(summary, str) and summary else None
866
+ ),
867
+ "output_tail": (
868
+ tail(redact_secrets(output), REASON_TAIL_CHARS)
869
+ if isinstance(output, str) and output else None
870
+ ),
871
+ "question": (
872
+ _cut(redact_secrets(question), REASON_SUMMARY_CHARS)
873
+ if isinstance(question, str) and question else None
874
+ ),
875
+ }
876
+ if row.get("s_id") is None:
877
+ reason["session"] = "not found"
878
+ return reason
879
+
880
+
881
+ def list_day_events(
882
+ *, db_path: Path, kind: str, now: int | None = None, limit: int | None = None,
883
+ ) -> dict:
884
+ """Today's events of one counted ``kind``, newest first, for the Today
885
+ counters (#257): ``{"kind", "events", "total", "truncated", "error"}``.
886
+ A failed or blocked event carries ``reason``. ``events`` and ``total``
887
+ are ``null`` when they could not be read, never an empty list."""
888
+ if limit is None:
889
+ limit = FEED_LIMIT
890
+ start, end = _local_day(int(now if now is not None else time.time()))
891
+ try:
892
+ events, total = _read(
893
+ Path(db_path),
894
+ lambda conn: _read_day_events(conn, kind, start, end, limit),
895
+ )
896
+ except Exception as exc:
897
+ return {
898
+ "kind": kind, "events": None, "total": None, "truncated": False,
899
+ "error": f"events could not be read ({db_path}): {type(exc).__name__}: {exc}",
900
+ }
901
+ return {
902
+ "kind": kind, "events": events, "total": total,
903
+ "truncated": total > len(events), "error": None,
904
+ }
905
+
906
+
795
907
  def make_sessions_reader(db_path: Path) -> SessionsReader:
796
908
  """A ``SessionsReader`` for ``WaitingTracker``: running sessions (oldest
797
909
  first) and repo locks, read-only. Raises on any database failure, which