ctrlrelay 0.13.0__tar.gz → 0.15.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 (167) hide show
  1. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/CHANGELOG.md +71 -1
  2. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/config/orchestrator.yaml.example +14 -0
  4. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/docs/architecture.md +8 -0
  5. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/docs/configuration.md +111 -3
  6. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/docs/feedback-loop.md +6 -3
  7. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/docs/operations.md +93 -0
  8. ctrlrelay-0.15.0/docs/superpowers/plans/2026-10-30-status-page.md +304 -0
  9. ctrlrelay-0.15.0/docs/superpowers/specs/2026-10-30-status-page-design.md +240 -0
  10. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/pyproject.toml +1 -1
  11. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/server.py +27 -0
  12. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/cli.py +283 -1
  13. ctrlrelay-0.15.0/src/ctrlrelay/core/agent_env.py +228 -0
  14. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/config.py +41 -1
  15. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/dispatcher.py +112 -2
  16. ctrlrelay-0.15.0/src/ctrlrelay/core/events.py +173 -0
  17. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/github.py +96 -5
  18. ctrlrelay-0.15.0/src/ctrlrelay/core/publish.py +189 -0
  19. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/state.py +356 -1
  20. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/worktree.py +289 -3
  21. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/install.py +5 -4
  22. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/dev.py +536 -57
  23. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/post_merge.py +18 -0
  24. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/secops.py +71 -0
  25. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/task.py +178 -26
  26. ctrlrelay-0.15.0/src/ctrlrelay/templates/systemd/web.service.template +22 -0
  27. ctrlrelay-0.15.0/src/ctrlrelay/web/__init__.py +1 -0
  28. ctrlrelay-0.15.0/src/ctrlrelay/web/health.py +167 -0
  29. ctrlrelay-0.15.0/src/ctrlrelay/web/links.py +35 -0
  30. ctrlrelay-0.15.0/src/ctrlrelay/web/server.py +347 -0
  31. ctrlrelay-0.15.0/src/ctrlrelay/web/static/index.html +684 -0
  32. ctrlrelay-0.15.0/src/ctrlrelay/web/status.py +745 -0
  33. ctrlrelay-0.15.0/src/ctrlrelay/web/transcript.py +529 -0
  34. ctrlrelay-0.15.0/src/ctrlrelay/web/waiting.py +534 -0
  35. ctrlrelay-0.15.0/tests/_publish_fakes.py +25 -0
  36. ctrlrelay-0.15.0/tests/fixtures/gh_search_issues.json +162 -0
  37. ctrlrelay-0.15.0/tests/fixtures/transcript_sample.jsonl +105 -0
  38. ctrlrelay-0.15.0/tests/test_agent_no_keys.py +1229 -0
  39. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_binary_resolution.py +3 -2
  40. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_claim_reconciliation.py +26 -0
  41. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_dev_integration.py +23 -0
  42. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_dev_pipeline.py +62 -70
  43. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_dispatch_outcome_hook.py +28 -14
  44. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_dispatcher.py +38 -19
  45. ctrlrelay-0.15.0/tests/test_event_emission.py +1065 -0
  46. ctrlrelay-0.15.0/tests/test_events.py +425 -0
  47. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_install.py +22 -1
  48. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_relay_alert_off_lock.py +2 -1
  49. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_resume_prompt_contract.py +14 -4
  50. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_task_pipeline.py +10 -0
  51. ctrlrelay-0.15.0/tests/test_web_health.py +228 -0
  52. ctrlrelay-0.15.0/tests/test_web_links.py +60 -0
  53. ctrlrelay-0.15.0/tests/test_web_server.py +1111 -0
  54. ctrlrelay-0.15.0/tests/test_web_status.py +1425 -0
  55. ctrlrelay-0.15.0/tests/test_web_transcript.py +645 -0
  56. ctrlrelay-0.15.0/tests/test_web_waiting.py +1171 -0
  57. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_worktree.py +0 -23
  58. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  59. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  60. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  61. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/.github/dependabot.yml +0 -0
  62. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/.github/workflows/build.yml +0 -0
  63. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/.github/workflows/cla.yml +0 -0
  64. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/.github/workflows/pages.yml +0 -0
  65. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/.github/workflows/publish.yml +0 -0
  66. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/.github/workflows/test.yml +0 -0
  67. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/.gitignore +0 -0
  68. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/CODE_OF_CONDUCT.md +0 -0
  69. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/CONTRIBUTING.md +0 -0
  70. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/LICENSE +0 -0
  71. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/README.md +0 -0
  72. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/SECURITY.md +0 -0
  73. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/docs/Gemfile +0 -0
  74. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/docs/_config.yml +0 -0
  75. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/docs/bridge.md +0 -0
  76. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/docs/cli.md +0 -0
  77. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/docs/development.md +0 -0
  78. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/docs/getting-started.md +0 -0
  79. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/docs/index.md +0 -0
  80. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/docs/personalization.md +0 -0
  81. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/__init__.py +0 -0
  82. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  83. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  84. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/factory.py +0 -0
  85. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/handler.py +0 -0
  86. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  87. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  88. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  89. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/__init__.py +0 -0
  90. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/archived.py +0 -0
  91. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/audit.py +0 -0
  92. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/binaries.py +0 -0
  93. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  94. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/code_review.py +0 -0
  95. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
  96. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/network.py +0 -0
  97. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/obs.py +0 -0
  98. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/poller.py +0 -0
  99. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  100. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  101. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  102. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/redact.py +0 -0
  103. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/core/scheduler.py +0 -0
  104. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  105. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/dashboard/client.py +0 -0
  106. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/gh_protocol.py +0 -0
  107. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  108. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/personalization/manager.py +0 -0
  109. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/personalization/paths.py +0 -0
  110. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  111. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/pipelines/base.py +0 -0
  112. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/setup.py +0 -0
  113. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/__init__.py +0 -0
  114. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  115. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  116. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  117. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  118. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  119. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  120. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  121. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/transports/__init__.py +0 -0
  122. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/transports/base.py +0 -0
  123. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  124. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  125. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/__init__.py +0 -0
  126. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/conftest.py +0 -0
  127. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_archived.py +0 -0
  128. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_audit.py +0 -0
  129. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_bridge_protocol.py +0 -0
  130. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_bridge_server.py +0 -0
  131. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_checkpoint.py +0 -0
  132. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_cli_ci_wait.py +0 -0
  133. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_cli_dev.py +0 -0
  134. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_cli_repos.py +0 -0
  135. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_cli_secops.py +0 -0
  136. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_cli_start.py +0 -0
  137. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_cli_version.py +0 -0
  138. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_code_review.py +0 -0
  139. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_config.py +0 -0
  140. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_dashboard_client.py +0 -0
  141. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_dispatch_health.py +0 -0
  142. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_docs_site.py +0 -0
  143. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_gh_protocol.py +0 -0
  144. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_github.py +0 -0
  145. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_issue_outcome_announcement.py +0 -0
  146. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_mattermost_handler.py +0 -0
  147. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_network.py +0 -0
  148. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_notify_transport.py +0 -0
  149. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_obs.py +0 -0
  150. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_personalization.py +0 -0
  151. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_pipeline_base.py +0 -0
  152. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_poller.py +0 -0
  153. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_post_merge.py +0 -0
  154. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_pr_verifier.py +0 -0
  155. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_pr_watcher.py +0 -0
  156. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_question_backoff.py +0 -0
  157. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_question_expiry.py +0 -0
  158. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_redact.py +0 -0
  159. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_scheduler.py +0 -0
  160. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_secops_integration.py +0 -0
  161. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_secops_pipeline.py +0 -0
  162. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_setup.py +0 -0
  163. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_state.py +0 -0
  164. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_telegram_handler.py +0 -0
  165. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_transport.py +0 -0
  166. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_transport_resolution.py +0 -0
  167. {ctrlrelay-0.13.0 → ctrlrelay-0.15.0}/tests/test_unreviewed_pr_announcement.py +0 -0
@@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.0] - 2026-10-31
11
+
12
+ The dev and task agents no longer inherit the usual GitHub credentials;
13
+ ctrlrelay does the pushing, PR opening and commenting itself
14
+ after the agent exits. One new
15
+ database table is added automatically and the new config values have
16
+ defaults, so **no config edit is required** to upgrade.
17
+
18
+ ### Changed
19
+
20
+ - Dev and task agents run with inherited `GH_*` and `GITHUB_*` variables
21
+ and gh's login removed from their environment, git credential helpers and
22
+ git's SSH disabled, and the D-Bus and runtime-dir variables pointed away
23
+ from the desktop keyring. After the agent exits the
24
+ relay pushes the run branch from a relay-private git dir to an explicit
25
+ GitHub URL, opens the PR from the DONE checkpoint (or reuses an open PR on the run
26
+ branch), pushes fix rounds,
27
+ fetches failed CI logs and posts task reports. It refuses to push the
28
+ default branch and only overwrites a remote branch whose head it recorded
29
+ pushing. secops keeps GitHub access (#235, closes #219)
30
+ - Status page: the phase bar shows the furthest phase seen in the recent
31
+ transcript and pipeline events, CI waits are
32
+ shown as waiting rather than reading, and the PR is linked (#239, closes
33
+ #238)
34
+
35
+ ### Added
36
+
37
+ - Optional `agent.sandbox: bwrap` with `agent.sandbox_hide_paths` to run the
38
+ dev and task agents under bubblewrap with credential paths hidden. Off by
39
+ default (#235)
40
+ - A `relay_pushes` table recording the heads ctrlrelay pushed (#235)
41
+ - Status page: a live counter and dot, server health, GitHub links, and the
42
+ list of skipped issues (#233, closes #232)
43
+
44
+ ### Known limits
45
+
46
+ - The agent still runs as the same OS user. Without the bwrap sandbox it
47
+ can read credential files on disk; with it, only the paths in
48
+ `agent.sandbox_hide_paths` are hidden. It can still run ssh directly, reach
49
+ the Linux keyring by resetting the D-Bus address, or read a token kept
50
+ under some other variable name, and the macOS keychain is not covered. Running the agent as a separate user
51
+ is tracked in #236
52
+
53
+ ## [0.14.0] - 2026-10-31
54
+
55
+ A read-only status page. `state.db` switches to WAL mode and gains an
56
+ `events` table; both happen automatically. The page is off until you start
57
+ it. **No config edit is required** to upgrade.
58
+
59
+ ### Added
60
+
61
+ - `ctrlrelay web start|stop|status` and a `ctrlrelay-web` systemd unit: a
62
+ read-only page plus `/api/status` and `/api/events` JSON showing what each
63
+ agent is doing (phase and last command), the waiting line, questions that
64
+ need you, today's counts, and a feed of dispatches, PRs, CI, reviews,
65
+ merges, messages and alerts. New `web:` config block (defaults
66
+ `127.0.0.1:8787`, 7 days). Requests for other host names are refused; add
67
+ LAN names to `web.allowed_hosts` (#217, closes #209)
68
+ - An `events` table, written best-effort by the daemon on a separate
69
+ connection with a 0.25s lock wait, so a slow or failed write never affects
70
+ the work it records (#217)
71
+
72
+ ### Changed
73
+
74
+ - `state.db` uses WAL mode so the page can read while the daemon writes. If
75
+ the switch cannot get the lock at startup it is deferred to the next start
76
+ instead of failing (#217)
77
+
10
78
  ## [0.13.0] - 2026-10-30
11
79
 
12
80
  New database columns and one new config value. All of them are added
@@ -1771,7 +1839,9 @@ pipeline).
1771
1839
  per-phase implementation plans (Phase 0 through Phase 4).
1772
1840
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1773
1841
 
1774
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.13.0...HEAD
1842
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.15.0...HEAD
1843
+ [0.15.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.14.0...v0.15.0
1844
+ [0.14.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.13.0...v0.14.0
1775
1845
  [0.13.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.4...v0.13.0
1776
1846
  [0.12.4]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.2...v0.12.4
1777
1847
  [0.12.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.2...v0.12.3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.13.0
3
+ Version: 0.15.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/
@@ -88,6 +88,20 @@ dashboard:
88
88
  url: "https://ctrlrelay-dashboard.example.com"
89
89
  auth_token_env: "CTRLRELAY_DASHBOARD_TOKEN"
90
90
 
91
+ # Read-only status page: `ctrlrelay web start`. Loopback by default; set
92
+ # host to this machine's LAN address to open it from the home network.
93
+ # It has no login: never expose it to the internet.
94
+ web:
95
+ host: "127.0.0.1"
96
+ port: 8787
97
+ history_days: 7
98
+ refresh_seconds: 5
99
+ github_check_seconds: 60
100
+ stale_after_minutes: 15
101
+ # Extra names you open the page by from another machine (the Host
102
+ # header); loopback, web.host and this machine's hostname are allowed.
103
+ allowed_hosts: []
104
+
91
105
  # Scheduled jobs run in-process by the poller daemon. Cron expressions are
92
106
  # standard 5-field (minute hour dom month dow) and evaluate in the `timezone`
93
107
  # set above. Change `secops_cron` to e.g. "0 6 * * 1" for weekly (Mondays).
@@ -70,6 +70,14 @@ is the only place ctrlrelay calls out to Claude Code.
70
70
  - **Environment additions:** `CTRLRELAY_SESSION_ID` and `CTRLRELAY_STATE_FILE`
71
71
  are the only contract between dispatcher and child. Anything else the agent
72
72
  needs (issue body, PR rules, etc.) goes into the prompt.
73
+ - **Credentials (#219):** every spawn says whether it gets GitHub access
74
+ (`spawn_session(..., github_access=...)`, no default). dev and task spawn
75
+ without it: GitHub tokens, `gh`'s login, git credential helpers, SSH and
76
+ the desktop keyring are cut off (`core/agent_env.py`), and ctrlrelay
77
+ pushes the branch, opens the PR or posts the report itself afterwards
78
+ (`core/publish.py`, `WorktreeManager.push_run_branch`). secops still
79
+ spawns with access. See
80
+ [What the agent can reach](configuration.md#what-the-agent-can-reach-219).
73
81
  - **Result:** `SessionResult(session_id, exit_code, state, stdout, stderr,
74
82
  agent_session_id)`. `session_id` is our composite orchestrator id;
75
83
  `agent_session_id` is Claude's own UUID, parsed from the JSON stdout and
@@ -26,6 +26,7 @@ paths: { ... }
26
26
  claude: { ... }
27
27
  transport: { ... }
28
28
  dashboard: { ... }
29
+ web: { ... }
29
30
  repos: [ ... ]
30
31
  ```
31
32
 
@@ -38,6 +39,7 @@ repos: [ ... ]
38
39
  | `claude` | object | no | (defaults) | See [claude](#claude). |
39
40
  | `transport` | object | **yes** | — | See [transport](#transport). |
40
41
  | `dashboard` | object | no | (defaults) | See [dashboard](#dashboard). |
42
+ | `web` | object | no | (defaults) | The read-only status page. See [web](#web). |
41
43
  | `repos` | list | no | `[]` | See [repos](#repos). |
42
44
  | `schedules` | object | no | (defaults) | See [schedules](#schedules). |
43
45
  | `personalization` | object | no | unset | Cross-machine sync of Claude state. See [personalization](#personalization) and [Personalization sync]({{ '/personalization/' | relative_url }}). |
@@ -86,7 +88,78 @@ claude:
86
88
 
87
89
  ctrlrelay always invokes claude with `--dangerously-skip-permissions` so the
88
90
  agent does not pause on tool-permission prompts in headless runs. This is
89
- intentional — the orchestrator runs unattended.
91
+ intentional — the orchestrator runs unattended. The CLI's other unattended
92
+ modes either need every shell command allow-listed or filter by command text,
93
+ which a wrapper script walks past, so they are not used as a guard. The guard
94
+ is the next section: the agent holds no GitHub credentials.
95
+
96
+ ### What the agent can reach (#219)
97
+
98
+ The dev and task agents run **without GitHub access**. ctrlrelay starts them
99
+ with:
100
+
101
+ - every `GH_*` and `GITHUB_*` variable removed (`GH_TOKEN`, `GITHUB_TOKEN`,
102
+ `GH_ENTERPRISE_TOKEN`, ...), and every inherited `CTRLRELAY_*` variable
103
+ (the chat bot tokens) except the two the checkpoint needs;
104
+ - `GH_CONFIG_DIR` pointed at a fresh empty directory, so `gh` finds no login;
105
+ - `DBUS_SESSION_BUS_ADDRESS` pointed at nothing, so `gh` and other libsecret
106
+ clients cannot fall back to the desktop keyring;
107
+ - `credential.helper` reset to empty (through `GIT_CONFIG_COUNT`),
108
+ `GIT_SSH_COMMAND=false` and `SSH_AUTH_SOCK` removed, so git cannot
109
+ authenticate over HTTPS or SSH, including through an `insteadOf` rewrite;
110
+ - `GIT_TERMINAL_PROMPT=0`, so nothing waits on a prompt.
111
+
112
+ Everything else is inherited unchanged, so the model CLI and the project's
113
+ toolchain behave as before. ctrlrelay then does the GitHub side itself, with
114
+ its own credentials, after the agent exits:
115
+
116
+ - **dev:** the agent commits on the run's branch and writes a PR title and
117
+ description in its DONE checkpoint. ctrlrelay pushes the branch and opens
118
+ the pull request. After a CI or conflict fix round it pushes again, and it
119
+ fetches the failed-job logs the agent used to read with `gh run view`.
120
+ - **task:** the agent writes its report in its DONE checkpoint and ctrlrelay
121
+ posts it as the issue comment.
122
+ - **secops** still runs **with** the operator's GitHub access. Its agent
123
+ reads Dependabot alerts and pull requests and merges what the policy
124
+ allows during the run. Moving it would mean ctrlrelay doing those reads
125
+ up front and the merges afterwards from a decision the agent returns.
126
+
127
+ Pushes are refused, and logged as `publish.push.refused`, for `main`,
128
+ `master`, the repository's default branch (read from the GitHub API, not
129
+ from the local clone) and any branch that is not the run's own. The push
130
+ goes to `https://github.com/<owner>/<repo>.git` (never `origin`) from a
131
+ private git dir with hooks off and only ctrlrelay's own git config, pushes the
132
+ commit it checked rather than the ref name, and uses `--force-with-lease`
133
+ against the head this session last pushed (recorded in the state DB). An
134
+ open PR found for the branch is reused only if its head repo is this repo and
135
+ its base is the default branch. Text the agent wrote is posted with `@`-mentions and
136
+ issue-closing keywords disabled and its length capped (20,000 characters);
137
+ the PR's own `Closes #N` is added by ctrlrelay.
138
+
139
+ **What this guarantees.** No GitHub credential is handed to the agent through
140
+ its environment. ctrlrelay chooses the push URL, the branch and the PR base.
141
+ `main`, `master`, the default branch and any branch other than the run's own
142
+ are refused, and a branch that already exists on GitHub is only pushed over if
143
+ its head is one ctrlrelay recorded pushing. Agent-written text is sanitized.
144
+ An agent that only uses its normal tooling (git, gh, ssh as configured) cannot
145
+ push. The push itself runs from a fresh relay-owned git dir under
146
+ `~/.ctrlrelay/push/`, reads the clone's branch heads from its ref files, and
147
+ never reads the clone's config, which every worktree shares.
148
+
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.
158
+
159
+ | Key | Type | Default | Description |
160
+ |---|---|---|---|
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. |
90
163
 
91
164
  ## transport
92
165
 
@@ -205,6 +278,36 @@ dashboard:
205
278
  The dashboard is optional. Leaving `url` empty (the default) is the supported
206
279
  no-op configuration.
207
280
 
281
+ ## web
282
+
283
+ The read-only status page served by `ctrlrelay web start` (see
284
+ [Operations: status page]({{ '/operations/' | relative_url }}#status-page)). Every key is optional;
285
+ without a `web:` block the defaults below apply.
286
+
287
+ ```yaml
288
+ web:
289
+ host: "127.0.0.1"
290
+ port: 8787
291
+ history_days: 7
292
+ refresh_seconds: 5
293
+ github_check_seconds: 60
294
+ stale_after_minutes: 15
295
+ allowed_hosts: [] # e.g. ["mybox", "mybox.lan"]
296
+ ```
297
+
298
+ | Key | Type | Default | Description |
299
+ |---|---|---|---|
300
+ | `host` | string | `"127.0.0.1"` | Address to listen on, exactly. Loopback by default so a fresh install is never reachable from other machines. Set it to this machine's LAN address to open the page from the home network. `0.0.0.0` listens on every interface and is warned about at startup. |
301
+ | `port` | int (1-65535) | `8787` | Port to listen on. |
302
+ | `history_days` | int (>= 1) | `7` | Days of feed the page shows, and what `/api/events` returns without `?since=`. |
303
+ | `refresh_seconds` | int (>= 1) | `5` | How often the page asks `/api/status` again. |
304
+ | `github_check_seconds` | int (>= 15) | `60` | How often the waiting line asks GitHub (one search, plus one query per `include_labels` label). |
305
+ | `stale_after_minutes` | int (>= 1) | `15` | A running card is marked "looks stuck" after this long with no new agent step, unless ctrlrelay itself has a CI wait or review open for it. |
306
+ | `allowed_hosts` | list of strings | `[]` | Extra names the page may be opened by. A request is served only when its `Host` header is `localhost`, `127.0.0.1`, `[::1]`, `host` (unless it is `0.0.0.0`/`::`), this machine's hostname, the hostname plus `.local`, or one of these, alone or followed by `:port`. Anything else, or no `Host`, gets `421`. Matching ignores case and a trailing dot. This stops a web page on another site from pointing its own name at this server (DNS rebinding) and reading it. |
307
+
308
+ The page has no login and shows repository names, issue titles and the
309
+ questions agents asked. **Never expose it to the internet.**
310
+
208
311
  ## repos
209
312
 
210
313
  A list of repositories the orchestrator manages.
@@ -253,7 +356,7 @@ and ask the operator), or `never` (skip).
253
356
  | `exclude_labels` | `["manual", "operator", "instruction"]` | Issue labels that tell the poller "this isn't for the agent". See [exclude_labels](#reposautomationexclude_labels) below. |
254
357
  | `include_labels` | `[]` | Issue labels that opt an issue **into** the dev pipeline regardless of who is (or isn't) assigned. See [include_labels](#reposautomationinclude_labels) below. |
255
358
  | `require_labels` | `[]` | Issue labels that gate the plain-assignment trigger: when set, assignment alone is no longer enough — the issue must also carry one of these labels. See [require_labels](#reposautomationrequire_labels) below. |
256
- | `ci_wait_timeout_seconds` | `600` | Hard cap, in seconds, on the single `ctrlrelay ci wait` call the dev pipeline prompt tells Claude to run before signaling DONE. See [ci_wait_timeout_seconds](#reposautomationci_wait_timeout_seconds) below. |
359
+ | `ci_wait_timeout_seconds` | `600` | Unused since #219 (the agent no longer waits on CI; ctrlrelay does). Still accepted so existing configs load. See [ci_wait_timeout_seconds](#reposautomationci_wait_timeout_seconds) below. |
257
360
 
258
361
  The current secops and dev pipelines read these settings to bias their prompts
259
362
  to Claude — they're not enforced by hard-coded checks.
@@ -400,7 +503,12 @@ repos:
400
503
 
401
504
  ### repos[].automation.ci_wait_timeout_seconds
402
505
 
403
- The dev pipeline prompt tells Claude to run `ctrlrelay ci wait --pr <PR>
506
+ **Not used by the dev pipeline since #219.** The agent no longer has GitHub
507
+ access, so it no longer waits on CI: ctrlrelay pushes the branch after the
508
+ agent exits and its own PR verifier waits on CI. The key is still accepted so
509
+ existing configs load. What follows describes the earlier behaviour.
510
+
511
+ The dev pipeline prompt told Claude to run `ctrlrelay ci wait --pr <PR>
404
512
  --repo <repo> --timeout <N>` before signaling DONE — a single blocking call
405
513
  that polls GitHub every 15s until CI finishes, fails, or the timeout hits.
406
514
  No tokens get generated while it's blocked; on a repo with a slow CI suite,
@@ -151,9 +151,12 @@ Step-by-step:
151
151
  15–17. **Loop until terminal.** If the resumed session blocks again, steps
152
152
  8–14 repeat (capped at `DEFAULT_MAX_BLOCKED_ROUNDS`, currently 5). On
153
153
  `DONE`/`FAILED`, the loop exits.
154
- 18. **PR verification.** If the dev pipeline got `DONE` with a `pr_url`, it
155
- verifies the PR is mergeable and CI is green. If not, it asks Claude to
156
- fix and re-verifies (up to `DEFAULT_MAX_FIX_ATTEMPTS`, currently 3).
154
+ 18. **Publish and verify.** On `DONE` the agent has only committed; it has
155
+ no GitHub access (#219). ctrlrelay pushes the run's branch, opens the PR
156
+ from the title and description in the checkpoint, then verifies the PR
157
+ is mergeable and CI is green. If not, it resumes the agent with the
158
+ failed-job logs, pushes the fix itself and re-verifies (up to
159
+ `DEFAULT_MAX_FIX_ATTEMPTS`, currently 3).
157
160
  19. **Operator notified.** The poller pushes a final `✅ PR ready: <url>` (or
158
161
  `❌ Failed: …`) message via the bridge.
159
162
 
@@ -21,6 +21,7 @@ ctrlrelay generates platform-appropriate service unit files for you with
21
21
  |---|---|---|
22
22
  | Bridge | `ctrlrelay bridge start` | Delivers BLOCKED questions to Telegram and routes answers back. |
23
23
  | Poller | `ctrlrelay poller start --interval 300` | Watches GitHub for newly assigned issues and runs the dev pipeline. |
24
+ | Status page (optional) | `ctrlrelay web start` | Read-only page and JSON API of what ctrlrelay is doing. See [Status page](#status-page). |
24
25
 
25
26
  Both should restart on failure and on login.
26
27
 
@@ -234,6 +235,97 @@ systemctl --user status ctrlrelay-poller
234
235
  journalctl --user -u ctrlrelay-poller -f
235
236
  ```
236
237
 
238
+ ## Status page
239
+
240
+ `ctrlrelay web start` serves a read-only page and JSON API showing what each
241
+ agent is doing (phase, current activity, last command), what is waiting and
242
+ why, the questions that need an answer, and a feed of what ctrlrelay did and
243
+ said. It is a third process next to the bridge and the poller, so a slow or
244
+ broken page can never stall them.
245
+
246
+ On a running card, **phase** (the bar) is the furthest step the session has
247
+ reached, from the agent's transcript plus ctrlrelay's own CI, review and PR
248
+ events, so it never moves back. **Current activity** is the most recent step:
249
+ an open CI wait or review if it started after the agent's latest step, else
250
+ that step. It can be earlier than the bar, for example a read after the PR
251
+ or a fix round after review. The card links the PR once ctrlrelay has
252
+ recorded its number.
253
+
254
+ It is **read-only**: it opens `state.db` read-only, reads the tail of each
255
+ agent's transcript, and asks GitHub (through the `gh` login ctrlrelay already
256
+ uses) for the waiting line once a minute. It writes nothing but its PID file
257
+ (`web.pid`, next to `state.db`), and serves `GET` only; any other method is
258
+ `405`. It has **no login** and shows repository names, issue titles and the
259
+ questions agents asked, so **never expose it to the internet**. Keep it on
260
+ loopback or on the home network.
261
+
262
+ | Path | What |
263
+ |---|---|
264
+ | `/` | The page. Refreshes itself every `web.refresh_seconds`. |
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
+ | `/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
+
268
+ The page header shows how long ago it last heard from the server ("updated
269
+ 12s ago", ticking every second) and a dot that is green while the last good
270
+ answer is at most two refresh intervals old and red otherwise. Elapsed times
271
+ on cards tick between polls too.
272
+
273
+ Fields added in #232 (all in `/api/status`):
274
+
275
+ | Field | What |
276
+ |---|---|
277
+ | `url` | On every `running`, `needs_you`, `waiting.items`, `skipped` and `feed` item (and on `/api/events` events): a link to the GitHub issue, or for a feed event that names a PR by number, the PR. Built only as `https://github.com/<owner>/<repo>/issues/<n>` or `/pull/<n>`, from a repo matching `owner/name` and an integer; `null` otherwise (a secops run with no issue, a restart). A `pr_url` an agent reported is never used. |
278
+ | `skipped` | Every open issue assigned to you that the poller will **not** pick up and is not running, from the same search as the waiting line (no extra GitHub calls): `repo`, `issue_number`, `title`, `url`, `updated`/`updated_ts` and `reason`, one of `repo not in config`, `excluded label <label>`, `already handled once (won't retry)`, `missing required label <label>` (several joined with "or": any one admits it), `assigned by someone else`. `null` until GitHub has answered once; `waiting.status` and `waiting.last_good_ts` say how current it is. The page shows it collapsed, with a count. |
279
+ | `server` | This machine's health, read on each request: `load_1m`, `load_5m`, `load_15m`, `cpu_count`, `memory` (`total_bytes`, `available_bytes`, `available_pct`, `low`), `disks` (one each for `home` and `worktrees`: `name`, `path`, `total_bytes`, `free_bytes`, `free_pct`, `low`), `uptime_s`, and `unavailable` (why any number is `null`; memory and uptime are `null` off Linux). `low` is true below 10% free and the page marks it. The whole section is `null`, with a line in `errors`, only if reading it failed outright. |
280
+
281
+ Try it on loopback first (the default `web.host` is `127.0.0.1`, port `8787`):
282
+
283
+ ```bash
284
+ ctrlrelay web start
285
+ ctrlrelay web status
286
+ curl -s http://127.0.0.1:8787/api/status | python3 -m json.tool | head -40
287
+ curl -s "http://127.0.0.1:8787/api/events?since=$(( $(date +%s) - 3600 ))"
288
+ ctrlrelay web stop
289
+ ```
290
+
291
+ To open it from another machine on the home network, set `web.host` in
292
+ `orchestrator.yaml` to this machine's LAN address (see
293
+ [web]({{ '/configuration/' | relative_url }}#web)), then run it under
294
+ systemd. `ctrlrelay install systemd` renders `ctrlrelay-web.service` with the
295
+ other two units. It refuses to overwrite units that already exist, so on a
296
+ host where the bridge and poller are installed, render into a scratch
297
+ directory and copy only the web unit:
298
+
299
+ ```bash
300
+ ctrlrelay install systemd --workdir ~/Projects/ctrlrelay --target-dir ~/ctrlrelay-units
301
+ cp ~/ctrlrelay-units/ctrlrelay-web.service ~/.config/systemd/user/
302
+ rm -r ~/ctrlrelay-units # the other two carry the chat token
303
+ systemctl --user daemon-reload
304
+ systemctl --user enable --now ctrlrelay-web.service
305
+ journalctl --user -u ctrlrelay-web -n 20
306
+ tail -n 20 ~/.ctrlrelay/logs/web.log
307
+ ```
308
+
309
+ The first line in `web.log` is a `web.started` event naming the address it
310
+ bound. If `web.host` is `0.0.0.0` it also logs a warning: that listens on
311
+ every interface, which is rarely what you want.
312
+
313
+ The page answers only to names it knows: `localhost`, `127.0.0.1`, `[::1]`,
314
+ `web.host`, and this machine's own hostname (plus `.local`). A request whose
315
+ `Host` header carries any other name gets `421 misdirected request`, and the
316
+ refused name is logged once as `web.misdirected_host`. So if you open it from
317
+ another machine by a name that is none of those, for example a DNS name your
318
+ router hands out, add that name to `web.allowed_hosts` and restart the unit:
319
+
320
+ ```yaml
321
+ web:
322
+ host: "192.168.1.20"
323
+ allowed_hosts: ["mybox.lan"]
324
+ ```
325
+
326
+ `grep misdirected_host ~/.ctrlrelay/logs/web.log` shows any name that was
327
+ refused, which is the quickest way to find the one to add.
328
+
237
329
  ## Scheduled jobs
238
330
 
239
331
  The poller daemon also hosts an in-process cron (APScheduler). The jobs run
@@ -300,6 +392,7 @@ Tail them:
300
392
  tail -f ~/.ctrlrelay/logs/poller.log
301
393
  tail -f ~/.ctrlrelay/logs/bridge.log
302
394
  tail -f ~/.ctrlrelay/logs/poller.error.log
395
+ tail -f ~/.ctrlrelay/logs/web.log # if the status page is installed
303
396
  ```
304
397
 
305
398
  The poller prints one line per detected issue and one line per pipeline outcome.