ctrlrelay 0.11.3__tar.gz → 0.12.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 (134) hide show
  1. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/CHANGELOG.md +15 -1
  2. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/PKG-INFO +18 -9
  3. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/README.md +15 -8
  4. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/config/orchestrator.yaml.example +33 -1
  5. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/docs/bridge.md +127 -10
  6. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/docs/configuration.md +56 -9
  7. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/pyproject.toml +8 -1
  8. ctrlrelay-0.12.0/src/ctrlrelay/bridge/__init__.py +34 -0
  9. ctrlrelay-0.12.0/src/ctrlrelay/bridge/__main__.py +120 -0
  10. ctrlrelay-0.12.0/src/ctrlrelay/bridge/factory.py +112 -0
  11. ctrlrelay-0.12.0/src/ctrlrelay/bridge/handler.py +95 -0
  12. ctrlrelay-0.12.0/src/ctrlrelay/bridge/mattermost_handler.py +443 -0
  13. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/bridge/server.py +389 -99
  14. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/bridge/telegram_handler.py +29 -12
  15. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/cli.py +276 -78
  16. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/config.py +101 -5
  17. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/state.py +16 -0
  18. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/pipelines/__init__.py +7 -1
  19. ctrlrelay-0.12.0/src/ctrlrelay/pipelines/base.py +114 -0
  20. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/pipelines/dev.py +16 -1
  21. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/pipelines/secops.py +25 -5
  22. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/pipelines/task.py +9 -1
  23. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/setup.py +51 -5
  24. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/transports/__init__.py +11 -4
  25. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/transports/socket_client.py +11 -1
  26. ctrlrelay-0.12.0/tests/conftest.py +126 -0
  27. ctrlrelay-0.12.0/tests/test_bridge_server.py +1769 -0
  28. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_cli_start.py +12 -4
  29. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_config.py +165 -0
  30. ctrlrelay-0.12.0/tests/test_mattermost_handler.py +808 -0
  31. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_obs.py +177 -183
  32. ctrlrelay-0.12.0/tests/test_pipeline_base.py +528 -0
  33. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_setup.py +239 -0
  34. ctrlrelay-0.12.0/tests/test_telegram_handler.py +88 -0
  35. ctrlrelay-0.12.0/tests/test_transport_resolution.py +327 -0
  36. ctrlrelay-0.11.3/src/ctrlrelay/bridge/__init__.py +0 -21
  37. ctrlrelay-0.11.3/src/ctrlrelay/bridge/__main__.py +0 -89
  38. ctrlrelay-0.11.3/src/ctrlrelay/pipelines/base.py +0 -50
  39. ctrlrelay-0.11.3/tests/conftest.py +0 -59
  40. ctrlrelay-0.11.3/tests/test_bridge_server.py +0 -1067
  41. ctrlrelay-0.11.3/tests/test_pipeline_base.py +0 -33
  42. ctrlrelay-0.11.3/tests/test_telegram_handler.py +0 -45
  43. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  44. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  45. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  46. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/.github/dependabot.yml +0 -0
  47. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/.github/workflows/build.yml +0 -0
  48. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/.github/workflows/cla.yml +0 -0
  49. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/.github/workflows/pages.yml +0 -0
  50. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/.github/workflows/publish.yml +0 -0
  51. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/.github/workflows/test.yml +0 -0
  52. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/.gitignore +0 -0
  53. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/CODE_OF_CONDUCT.md +0 -0
  54. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/CONTRIBUTING.md +0 -0
  55. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/LICENSE +0 -0
  56. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/SECURITY.md +0 -0
  57. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/docs/Gemfile +0 -0
  58. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/docs/_config.yml +0 -0
  59. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/docs/architecture.md +0 -0
  60. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/docs/cli.md +0 -0
  61. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/docs/development.md +0 -0
  62. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/docs/feedback-loop.md +0 -0
  63. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/docs/getting-started.md +0 -0
  64. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/docs/index.md +0 -0
  65. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/docs/operations.md +0 -0
  66. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/docs/personalization.md +0 -0
  67. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/__init__.py +0 -0
  68. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  69. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/__init__.py +0 -0
  70. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/archived.py +0 -0
  71. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/audit.py +0 -0
  72. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  73. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/code_review.py +0 -0
  74. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/dispatcher.py +0 -0
  75. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/github.py +0 -0
  76. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/network.py +0 -0
  77. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/obs.py +0 -0
  78. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/poller.py +0 -0
  79. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  80. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  81. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  82. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/scheduler.py +0 -0
  83. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/core/worktree.py +0 -0
  84. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  85. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/dashboard/client.py +0 -0
  86. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/gh_protocol.py +0 -0
  87. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/install.py +0 -0
  88. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  89. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/personalization/manager.py +0 -0
  90. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/personalization/paths.py +0 -0
  91. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  92. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/__init__.py +0 -0
  93. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  94. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  95. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  96. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  97. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  98. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  99. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  100. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/transports/base.py +0 -0
  101. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  102. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/__init__.py +0 -0
  103. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_archived.py +0 -0
  104. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_audit.py +0 -0
  105. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_bridge_protocol.py +0 -0
  106. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_checkpoint.py +0 -0
  107. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_cli_ci_wait.py +0 -0
  108. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_cli_dev.py +0 -0
  109. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_cli_repos.py +0 -0
  110. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_cli_secops.py +0 -0
  111. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_cli_version.py +0 -0
  112. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_code_review.py +0 -0
  113. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_dashboard_client.py +0 -0
  114. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_dev_integration.py +0 -0
  115. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_dev_pipeline.py +0 -0
  116. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_dispatcher.py +0 -0
  117. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_docs_site.py +0 -0
  118. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_gh_protocol.py +0 -0
  119. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_github.py +0 -0
  120. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_install.py +0 -0
  121. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_network.py +0 -0
  122. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_personalization.py +0 -0
  123. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_poller.py +0 -0
  124. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_post_merge.py +0 -0
  125. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_pr_verifier.py +0 -0
  126. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_pr_watcher.py +0 -0
  127. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_question_expiry.py +0 -0
  128. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_scheduler.py +0 -0
  129. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_secops_integration.py +0 -0
  130. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_secops_pipeline.py +0 -0
  131. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_state.py +0 -0
  132. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_task_pipeline.py +0 -0
  133. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_transport.py +0 -0
  134. {ctrlrelay-0.11.3 → ctrlrelay-0.12.0}/tests/test_worktree.py +0 -0
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.0] - 2026-10-06
11
+
12
+ ### Added
13
+
14
+ - Mattermost transport, and a transport-neutral bridge (#180)
15
+
16
+ ### Fixed
17
+
18
+ - Write the transport the operator asked for, and document the choice (#184)
19
+ - Resolve the bridge socket for whichever transport is set (#183)
20
+ - Say the real reason instead of "Unknown error" (#182)
21
+ - Refuse a reply whose reply-to names a non-routable session (#178)
22
+
10
23
  ## [0.11.3] - 2026-09-12
11
24
 
12
25
  ### Fixed
@@ -1658,7 +1671,8 @@ pipeline).
1658
1671
  per-phase implementation plans (Phase 0 through Phase 4).
1659
1672
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1660
1673
 
1661
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.3...HEAD
1674
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.0...HEAD
1675
+ [0.12.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.3...v0.12.0
1662
1676
  [0.11.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.2...v0.11.3
1663
1677
  [0.11.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.1...v0.11.2
1664
1678
  [0.11.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.0...v0.11.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.11.3
3
+ Version: 0.12.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/
@@ -25,11 +25,13 @@ Classifier: Topic :: Software Development :: Version Control :: Git
25
25
  Classifier: Topic :: System :: Systems Administration
26
26
  Requires-Python: >=3.12
27
27
  Requires-Dist: apscheduler>=3.10.0
28
+ Requires-Dist: httpx>=0.27.0
28
29
  Requires-Dist: pydantic>=2.0.0
29
30
  Requires-Dist: python-telegram-bot>=21.0
30
31
  Requires-Dist: pyyaml>=6.0.0
31
32
  Requires-Dist: rich>=13.0.0
32
33
  Requires-Dist: typer>=0.12.0
34
+ Requires-Dist: websockets>=12.0
33
35
  Provides-Extra: dev
34
36
  Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
35
37
  Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
@@ -48,7 +50,8 @@ Description-Content-Type: text/markdown
48
50
 
49
51
  > Local-first orchestrator for headless coding agents across your GitHub
50
52
  > repos. Watches for assigned issues, runs a dev pipeline in an isolated
51
- > git worktree, opens a PR, and asks you on Telegram when it gets stuck.
53
+ > git worktree, opens a PR, and asks you on Telegram or Mattermost when it
54
+ > gets stuck.
52
55
 
53
56
  ## Table of Contents
54
57
 
@@ -78,7 +81,7 @@ Your agent credentials, your GitHub credentials, your repos, your
78
81
  machine.
79
82
 
80
83
  Today `ctrlrelay` ships with a Claude Code (`claude -p`) backend. The
81
- orchestrator layer — worktrees, state DB, scheduler, Telegram bridge —
84
+ orchestrator layer — worktrees, state DB, scheduler, chat bridge —
82
85
  is agent-agnostic, and plug-in backends for other headless coding agents
83
86
  are on the roadmap (see [Roadmap](#roadmap)).
84
87
 
@@ -90,11 +93,15 @@ are on the roadmap (see [Roadmap](#roadmap)).
90
93
  and opens a PR. Label triggers let a teammate without rights on your
91
94
  account flag an issue as safe for the bot to pick up —
92
95
  see [`include_labels`][docs-config].
93
- - **Telegram bridge.** When a session hits a blocking question, the
94
- bridge relays it to you as a DM and resumes the session once you
95
- reply.
96
+ - **Chat bridge.** When a session hits a blocking question, the bridge
97
+ relays it to you and resumes the session once you reply. Choose
98
+ **Telegram** (a DM from a bot) or **Mattermost** (a channel on your own
99
+ server) with one `transport.type` setting. Exactly one is active at a
100
+ time; running both at once is not supported yet
101
+ ([#176](https://github.com/AInvirion/ctrlrelay/issues/176)). See
102
+ [configuration][docs-config].
96
103
  - **PR watcher.** Tracks the opened PR to merge and closes the loop
97
- with a Telegram notification.
104
+ with a notification on whichever chat transport is configured.
98
105
  - **In-process scheduler** (APScheduler). Runs periodic jobs inside
99
106
  the poller daemon. Ships with a `secops` job that reviews Dependabot
100
107
  alerts and PRs daily at 6am; cron expressions follow standard Vixie
@@ -153,8 +160,10 @@ a launchd/systemd-supervised daemon.
153
160
  independent reviewer for the agent's output; you can disable it by
154
161
  setting `code_review.method: "none"` in your config if you prefer to
155
162
  skip the review step.
156
- - *(Optional)* a Telegram bot token if you want the bridge — see
157
- [Telegram bridge docs][docs-bridge].
163
+ - *(Optional)* if you want the bridge, credentials for one chat
164
+ transport: a **Telegram** bot token, or a **Mattermost** server URL,
165
+ bot token and channel ID — see [bridge docs][docs-bridge] and
166
+ [configuration][docs-config].
158
167
 
159
168
  ### Installation
160
169
 
@@ -9,7 +9,8 @@
9
9
 
10
10
  > Local-first orchestrator for headless coding agents across your GitHub
11
11
  > repos. Watches for assigned issues, runs a dev pipeline in an isolated
12
- > git worktree, opens a PR, and asks you on Telegram when it gets stuck.
12
+ > git worktree, opens a PR, and asks you on Telegram or Mattermost when it
13
+ > gets stuck.
13
14
 
14
15
  ## Table of Contents
15
16
 
@@ -39,7 +40,7 @@ Your agent credentials, your GitHub credentials, your repos, your
39
40
  machine.
40
41
 
41
42
  Today `ctrlrelay` ships with a Claude Code (`claude -p`) backend. The
42
- orchestrator layer — worktrees, state DB, scheduler, Telegram bridge —
43
+ orchestrator layer — worktrees, state DB, scheduler, chat bridge —
43
44
  is agent-agnostic, and plug-in backends for other headless coding agents
44
45
  are on the roadmap (see [Roadmap](#roadmap)).
45
46
 
@@ -51,11 +52,15 @@ are on the roadmap (see [Roadmap](#roadmap)).
51
52
  and opens a PR. Label triggers let a teammate without rights on your
52
53
  account flag an issue as safe for the bot to pick up —
53
54
  see [`include_labels`][docs-config].
54
- - **Telegram bridge.** When a session hits a blocking question, the
55
- bridge relays it to you as a DM and resumes the session once you
56
- reply.
55
+ - **Chat bridge.** When a session hits a blocking question, the bridge
56
+ relays it to you and resumes the session once you reply. Choose
57
+ **Telegram** (a DM from a bot) or **Mattermost** (a channel on your own
58
+ server) with one `transport.type` setting. Exactly one is active at a
59
+ time; running both at once is not supported yet
60
+ ([#176](https://github.com/AInvirion/ctrlrelay/issues/176)). See
61
+ [configuration][docs-config].
57
62
  - **PR watcher.** Tracks the opened PR to merge and closes the loop
58
- with a Telegram notification.
63
+ with a notification on whichever chat transport is configured.
59
64
  - **In-process scheduler** (APScheduler). Runs periodic jobs inside
60
65
  the poller daemon. Ships with a `secops` job that reviews Dependabot
61
66
  alerts and PRs daily at 6am; cron expressions follow standard Vixie
@@ -114,8 +119,10 @@ a launchd/systemd-supervised daemon.
114
119
  independent reviewer for the agent's output; you can disable it by
115
120
  setting `code_review.method: "none"` in your config if you prefer to
116
121
  skip the review step.
117
- - *(Optional)* a Telegram bot token if you want the bridge — see
118
- [Telegram bridge docs][docs-bridge].
122
+ - *(Optional)* if you want the bridge, credentials for one chat
123
+ transport: a **Telegram** bot token, or a **Mattermost** server URL,
124
+ bot token and channel ID — see [bridge docs][docs-bridge] and
125
+ [configuration][docs-config].
119
126
 
120
127
  ### Installation
121
128
 
@@ -31,8 +31,22 @@ agent:
31
31
  default_timeout_seconds: 1800
32
32
  output_format: "json"
33
33
 
34
+ # EXACTLY ONE transport is active, and it is the one named by `type`.
35
+ # The other blocks below are inert: they may stay in the file, but nothing
36
+ # reads them. Running Telegram and Mattermost at the same time is NOT
37
+ # supported - that is tracked as issue #176.
38
+ #
39
+ # Choose:
40
+ # type: "telegram" production, 1:1 chat with a bot
41
+ # type: "mattermost" production, a channel on your own server
42
+ # type: "file_mock" no chat at all; questions are written to files.
43
+ # Blocked sessions still persist and can be resumed
44
+ # later, but nobody is asked anything.
45
+ #
46
+ # Leaving a stale block for the transport you are NOT using is harmless,
47
+ # but delete it if you want the file to say what is true.
34
48
  transport:
35
- type: "file_mock" # Use "telegram" for production
49
+ type: "file_mock" # "telegram" or "mattermost" for production
36
50
  telegram:
37
51
  bot_token_env: "CTRLRELAY_TELEGRAM_TOKEN"
38
52
  chat_id: 123456789
@@ -44,6 +58,24 @@ transport:
44
58
  # to cover being asleep: a sweep runs its repos one at a time and
45
59
  # holds each repo's lock and worktree for the whole wait.
46
60
  ask_timeout_seconds: 900
61
+ mattermost:
62
+ url: "https://chat.example.com"
63
+ bot_token_env: "CTRLRELAY_MATTERMOST_TOKEN"
64
+ # The 26-character channel ID, not the channel name: a name is only
65
+ # unique within a team and can be renamed under you. Channel menu ->
66
+ # View Info, or GET /api/v4/teams/name/{team}/channels/name/{channel}.
67
+ #
68
+ # The bot must be a MEMBER of this channel. A bot without `post:all`
69
+ # cannot post where it is not a member - and, the part that is easy to
70
+ # miss, it does not receive that channel's reply events either, so
71
+ # answers would silently never arrive.
72
+ channel_id: "abcdefghijklmnopqrstuvwxyz"
73
+ socket_path: "~/.ctrlrelay/ctrlrelay.sock"
74
+ ask_timeout_seconds: 900
75
+ # How long an UNANSWERED question stays routable before it is retired.
76
+ # Read from whichever transport is active, so a Mattermost deployment
77
+ # takes this value and not the telegram block's.
78
+ question_ttl_seconds: 172800
47
79
  file_mock:
48
80
  inbox: "~/.ctrlrelay/inbox.txt"
49
81
  outbox: "~/.ctrlrelay/outbox.txt"
@@ -1,19 +1,45 @@
1
1
  ---
2
- title: Telegram bridge
2
+ title: Chat bridge
3
3
  layout: default
4
4
  nav_order: 4
5
- description: "Set up the Telegram bridge that delivers BLOCKED questions to a human and routes their answers back to a paused Claude session."
5
+ description: "Set up the chat bridge — Telegram or Mattermost — that delivers BLOCKED questions to a human and routes their answers back to a paused Claude session."
6
6
  permalink: /bridge/
7
7
  ---
8
8
 
9
- # Telegram bridge
9
+ # Chat bridge
10
10
 
11
11
  The bridge is a small daemon that:
12
12
 
13
- 1. Listens on a Unix socket at `transport.telegram.socket_path`.
14
- 2. Forwards messages from ctrlrelay to a Telegram chat over the Bot API.
15
- 3. Long-polls Telegram for replies in that chat and delivers them back to the
16
- socket client that asked.
13
+ 1. Listens on a Unix socket at the configured transport's `socket_path`.
14
+ 2. Forwards messages from ctrlrelay to a chat channel.
15
+ 3. Streams replies back from that channel and delivers them to the socket
16
+ client that asked.
17
+
18
+ It speaks **Telegram** or **Mattermost**. Pipelines never know which: they
19
+ talk to the socket, and the chat app is the bridge's business. Switching is a
20
+ one-line config change and no pipeline code moves.
21
+
22
+ **One at a time.** `transport.type` names the transport that is active, and the
23
+ other block is inert — it may stay in the file, but nothing reads it, including
24
+ its `question_ttl_seconds`. There is no fallback: a question goes to the one
25
+ you named or nowhere. Running both at once is
26
+ [issue #176](https://github.com/AInvirion/ctrlrelay/issues/176) and is not
27
+ built.
28
+
29
+ | | Telegram | Mattermost |
30
+ |---|---|---|
31
+ | Needs | a bot from BotFather, your chat id | a self-hosted server, a bot account, a channel id |
32
+ | Outbound | Bot API `sendMessage` | `POST /api/v4/posts` |
33
+ | Inbound | `getUpdates` long-poll | WebSocket `posted` events |
34
+ | "answer this one" | the reply gesture | reply **in the question's thread** |
35
+ | Tappable choices | reply keyboard | rendered as a numbered list you type |
36
+
37
+ **The one operator-facing difference worth knowing.** When several questions
38
+ are outstanding, the bridge can only route your answer if it knows which
39
+ question you mean. On Telegram that is Telegram's reply; on Mattermost it is
40
+ **replying inside the question's thread**. A loose message in the channel is
41
+ only routable when exactly one question is waiting — otherwise the bridge
42
+ refuses and tells you what is outstanding, rather than guessing.
17
43
 
18
44
  Pipelines use it as the human-in-the-loop channel: when Claude writes a
19
45
  `BLOCKED_NEEDS_INPUT` checkpoint, the dev pipeline calls `transport.ask(question)`,
@@ -108,6 +134,91 @@ Stop it:
108
134
  ctrlrelay bridge stop
109
135
  ```
110
136
 
137
+ ## Mattermost instead of Telegram
138
+
139
+ Steps 1–2 above are Telegram-specific. For Mattermost, do this instead and
140
+ then rejoin at step 4.
141
+
142
+ ### M1 — Enable bot accounts
143
+
144
+ **System Console → Integrations → Bot Accounts → Enable Bot Account Creation
145
+ = true.**
146
+
147
+ On a self-hosted install this is off by default. Note that editing
148
+ `config.json` on the server may not be enough — if the instance has no config
149
+ watcher the value will not take effect until `systemctl restart mattermost`,
150
+ and the live value is what matters. Read it back before believing it:
151
+
152
+ ```bash
153
+ curl -s "https://<your-server>/api/v4/config/client?format=old" \
154
+ | jq .EnableBotAccountCreation
155
+ ```
156
+
157
+ `EnableUserAccessTokens` is **not** required. That setting governs *user*
158
+ personal access tokens; bot account tokens are managed separately. Leaving it
159
+ off avoids letting every user mint long-lived full-access tokens.
160
+
161
+ ### M2 — Create the bot and its token
162
+
163
+ 1. **Integrations → Bot Accounts → Add Bot Account.**
164
+ 2. Username `ctrlrelay`, role **Member**. Leave `post:all` and `post:channels`
165
+ **off** — the bot will be added to one channel, and a token with no reach
166
+ beyond it is a smaller problem if it leaks.
167
+ 3. **Create New Token** and copy it immediately. Mattermost shows it once.
168
+
169
+ ### M3 — A channel, and put the bot in it
170
+
171
+ Create a channel for orchestrator questions, then invite the bot:
172
+
173
+ ```
174
+ /invite @ctrlrelay
175
+ ```
176
+
177
+ **This is not optional, and the reason is easy to miss.** A bot without
178
+ `post:all` cannot post where it is not a member — and it does not receive the
179
+ reply events for such a channel either. Forget this and questions fail to post
180
+ *and* answers would never arrive. `ctrlrelay bridge start` checks membership
181
+ before it binds its socket and refuses to start until the bot is invited.
182
+
183
+ The bot must also be on the team. If `/invite` complains, add it via the
184
+ team's **Invite People** first.
185
+
186
+ ### M4 — Find the channel id
187
+
188
+ The id, not the name: a name is only unique within a team and can be renamed
189
+ under you, while the id is stable. From the channel's **View Info**, or:
190
+
191
+ ```bash
192
+ curl -s -H "Authorization: Bearer $CTRLRELAY_MATTERMOST_TOKEN" \
193
+ "https://<your-server>/api/v4/teams/name/<team>/channels/name/<channel>" \
194
+ | jq -r .id
195
+ ```
196
+
197
+ ### M5 — Configure
198
+
199
+ ```bash
200
+ export CTRLRELAY_MATTERMOST_TOKEN="your-bot-token"
201
+ ```
202
+
203
+ ```yaml
204
+ transport:
205
+ type: "mattermost"
206
+ mattermost:
207
+ url: "https://chat.example.com" # scheme required
208
+ bot_token_env: "CTRLRELAY_MATTERMOST_TOKEN"
209
+ channel_id: "emzhur1hwpyc38ehkfm5ppym8y"
210
+ socket_path: "~/.ctrlrelay/ctrlrelay.sock"
211
+ ask_timeout_seconds: 900
212
+ ```
213
+
214
+ Then `ctrlrelay config validate`, and continue from step 4 above —
215
+ `ctrlrelay bridge start` is the same command for either transport.
216
+
217
+ **Editions.** Everything here works on free self-hosted Mattermost. The REST
218
+ API, the WebSocket, bot accounts and bot tokens are core features, not
219
+ licensed ones; the paid tiers cover SSO/SAML/LDAP, compliance export and high
220
+ availability, none of which the bridge touches.
221
+
111
222
  ## 5 — Send a test message
112
223
 
113
224
  Once the bridge is running and reachable on its socket, send a one-off message
@@ -186,17 +297,23 @@ no running process", remove the orphan socket file (`rm
186
297
  ## Sequence: BLOCKED question round-trip
187
298
 
188
299
  ```
189
- pipeline bridge Telegram user
300
+ pipeline bridge chat app user
190
301
  │ │ │ │
191
302
  │── ask("Which?") ────>│ │ │
192
- │ │── sendMessage ──────>│ │
303
+ │ │─ post question ─────>│ │
193
304
  │ │ │── push ──────>│
194
305
  │ │ │ │
195
306
  │ │ │<── reply ─────│
196
- │ │<── getUpdates ───────│ │
307
+ │ │<─ reply + post id ───│ │
197
308
  │<── answer("the b") ──│ │ │
198
309
  ```
199
310
 
311
+ The bridge remembers the id of every question it posts, so the reply's
312
+ "this is what I am answering" — Telegram's `reply_to_message_id`, Mattermost's
313
+ thread `root_id` — names the session exactly. That is also how an answer
314
+ arriving *after* the pipeline stopped waiting still drives a resume, via the
315
+ `pending_resumes` table.
316
+
200
317
  The pipeline's `transport.ask()` call blocks (with the configured timeout) until
201
318
  the bridge returns the answer. The pipeline then resumes the Claude session via
202
319
  `claude --resume <session_id>` with a prompt of the form "User answered: …".
@@ -91,26 +91,52 @@ intentional — the orchestrator runs unattended.
91
91
  ## transport
92
92
 
93
93
  The transport carries `BLOCKED_NEEDS_INPUT` questions out of ctrlrelay to a
94
- human and routes the answer back. Pick one of two types.
94
+ human and routes the answer back.
95
+
96
+ ### Choosing one
97
+
98
+ **Exactly one transport is active: the one named by `type`.** Every other
99
+ block in the file is inert — it may stay there, but nothing reads it.
100
+
101
+ | You want | Set `type` to | You need |
102
+ |---|---|---|
103
+ | A 1:1 chat with a bot | `"telegram"` | A bot token and your chat ID. |
104
+ | A channel on your own server | `"mattermost"` | A server URL, a bot token, and the channel's 26-character ID. |
105
+ | No chat at all | `"file_mock"` | Nothing. Questions go to a file; **nobody is asked anything.** |
106
+
107
+ **Running Telegram and Mattermost at the same time is not supported.** Setting
108
+ `type` to one does not fall back to the other, and a question is delivered to
109
+ one place or not at all. That is tracked as
110
+ [issue #176](https://github.com/AInvirion/ctrlrelay/issues/176).
111
+
112
+ Switching is a two-line change — set `type`, add that transport's block — plus
113
+ a restart of **both** daemons, which share `state.db`.
114
+
115
+ Every per-transport setting below is read from the **active** transport. A
116
+ Mattermost deployment takes `question_ttl_seconds` from the `mattermost` block,
117
+ not from a leftover `telegram` one.
95
118
 
96
119
  ```yaml
97
120
  transport:
98
- type: "telegram" # or "file_mock"
99
- telegram:
100
- bot_token_env: "CTRLRELAY_TELEGRAM_TOKEN"
101
- chat_id: 123456789
121
+ type: "mattermost" # or "telegram", or "file_mock"
122
+ mattermost:
123
+ url: "https://chat.example.com"
124
+ bot_token_env: "CTRLRELAY_MATTERMOST_TOKEN"
125
+ channel_id: "abcdefghijklmnopqrstuvwxyz"
102
126
  socket_path: "~/.ctrlrelay/ctrlrelay.sock"
103
- file_mock:
104
- inbox: "~/.ctrlrelay/inbox.txt"
105
- outbox: "~/.ctrlrelay/outbox.txt"
106
127
  ```
107
128
 
108
129
  | Key | Type | Required | Default | Description |
109
130
  |---|---|---|---|---|
110
- | `type` | enum | no | `"file_mock"` | One of `"telegram"`, `"file_mock"`. |
131
+ | `type` | enum | no | `"file_mock"` | One of `"telegram"`, `"mattermost"`, `"file_mock"`. |
111
132
  | `telegram` | object | required when `type=telegram` | — | Telegram bridge settings — see below. |
133
+ | `mattermost` | object | required when `type=mattermost` | — | Mattermost bridge settings — see below. |
112
134
  | `file_mock` | object | required when `type=file_mock` | — | Local-file fake transport, used for tests/dev. |
113
135
 
136
+ The block for the selected type is **required**: a config naming a type whose
137
+ block is absent is refused at load, loudly, rather than starting with no way to
138
+ reach anybody.
139
+
114
140
  ### transport.telegram
115
141
 
116
142
  | Key | Type | Default | Description |
@@ -123,6 +149,27 @@ transport:
123
149
 
124
150
  See [Telegram bridge]({{ '/bridge/' | relative_url }}) for the full setup walkthrough.
125
151
 
152
+ ### transport.mattermost
153
+
154
+ | Key | Type | Default | Description |
155
+ |---|---|---|---|
156
+ | `url` | string | — | Base URL of your Mattermost server, e.g. `https://chat.example.com`. Required. |
157
+ | `bot_token_env` | string | `"CTRLRELAY_MATTERMOST_TOKEN"` | Name of the environment variable holding the bot token. ctrlrelay never reads the token directly — only the variable name. |
158
+ | `channel_id` | string | `""` | The channel's **26-character ID**, not its name: a name is only unique within a team and can be renamed under you. Find it in the channel menu under *View Info*, or via `GET /api/v4/teams/name/{team}/channels/name/{channel}`. |
159
+ | `socket_path` | path | `"~/.ctrlrelay/ctrlrelay.sock"` | Unix socket path the bridge listens on. Pipelines connect to this socket as clients. |
160
+ | `ask_timeout_seconds` | int | `900` | Same meaning as the Telegram key above: it bounds only the in-session wait, and a later reply still resolves through `pending_resumes`. |
161
+ | `question_ttl_seconds` | int | `172800` (48h) | Same meaning as the Telegram key above. |
162
+
163
+ **The bot must be a member of that channel.** A bot without `post:all` cannot
164
+ post where it is not a member — and, the part that is easy to miss, it does not
165
+ receive that channel's reply events either, so answers would silently never
166
+ arrive.
167
+
168
+ Outbound is `POST /api/v4/posts`; inbound is a WebSocket on
169
+ `/api/v4/websocket` filtered to the one channel. A threaded reply's `root_id`
170
+ equals the question's post ID, which is how an answer is matched to its
171
+ question — the same role `reply_to_message_id` plays on Telegram.
172
+
126
173
  ### transport.file_mock
127
174
 
128
175
  | Key | Type | Required | Description |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.11.3"
3
+ version = "0.12.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"
@@ -32,6 +32,13 @@ dependencies = [
32
32
  "rich>=13.0.0",
33
33
  "python-telegram-bot>=21.0",
34
34
  "apscheduler>=3.10.0",
35
+ # Mattermost transport: REST out, WebSocket in. Both are permissive
36
+ # licences (httpx BSD-3-Clause, websockets BSD-3-Clause) and actively
37
+ # maintained. A Mattermost-specific client library was considered and
38
+ # skipped: the surface used here is four endpoints and one event type,
39
+ # which is less code than keeping a wrapper current.
40
+ "httpx>=0.27.0",
41
+ "websockets>=12.0",
35
42
  ]
36
43
 
37
44
  [project.optional-dependencies]
@@ -0,0 +1,34 @@
1
+ """Bridge process: a Unix socket on one side, a chat app on the other."""
2
+
3
+ from ctrlrelay.bridge.factory import (
4
+ HandlerConfigError,
5
+ make_handler,
6
+ token_env_var,
7
+ )
8
+ from ctrlrelay.bridge.handler import ChatHandler, IncomingMessageHandler
9
+ from ctrlrelay.bridge.mattermost_handler import MattermostHandler
10
+ from ctrlrelay.bridge.protocol import (
11
+ BridgeMessage,
12
+ BridgeOp,
13
+ ProtocolError,
14
+ parse_message,
15
+ serialize_message,
16
+ )
17
+ from ctrlrelay.bridge.server import BridgeServer
18
+ from ctrlrelay.bridge.telegram_handler import TelegramHandler
19
+
20
+ __all__ = [
21
+ "BridgeMessage",
22
+ "BridgeOp",
23
+ "BridgeServer",
24
+ "ChatHandler",
25
+ "HandlerConfigError",
26
+ "IncomingMessageHandler",
27
+ "MattermostHandler",
28
+ "ProtocolError",
29
+ "TelegramHandler",
30
+ "make_handler",
31
+ "parse_message",
32
+ "serialize_message",
33
+ "token_env_var",
34
+ ]
@@ -0,0 +1,120 @@
1
+ """Bridge process entry point for daemon mode.
2
+
3
+ Takes a config path rather than per-transport flags. The alternative —
4
+ growing a flag per chat app — means every new transport touches this file,
5
+ the CLI that spawns it, and the two of them have to agree. Reading the same
6
+ config the CLI read removes that agreement from the design.
7
+
8
+ The token is never an argument. It is read from the environment variable
9
+ the config names, which the parent process passes through, because argv is
10
+ world-readable via ``ps`` and ``/proc/*/cmdline``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import asyncio
17
+ import signal
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from ctrlrelay.bridge import BridgeServer, HandlerConfigError, make_handler
22
+ from ctrlrelay.core.config import ConfigError, load_config, resolve_config_path
23
+
24
+
25
+ def main() -> None:
26
+ parser = argparse.ArgumentParser(description="ctrlrelay chat bridge")
27
+ parser.add_argument(
28
+ "--config",
29
+ default=None,
30
+ help="Path to orchestrator.yaml (default: auto-discover).",
31
+ )
32
+ parser.add_argument(
33
+ "--socket-path",
34
+ default=None,
35
+ help="Unix socket path. Defaults to the configured transport's.",
36
+ )
37
+ parser.add_argument(
38
+ "--state-db",
39
+ default=None,
40
+ help=(
41
+ "Path to the orchestrator state.db. When provided, orphan "
42
+ "replies route to persisted BLOCKED sessions in "
43
+ "pending_resumes. Required for the resume-via-chat flow."
44
+ ),
45
+ )
46
+ args = parser.parse_args()
47
+
48
+ try:
49
+ config = load_config(resolve_config_path(args.config))
50
+ except ConfigError as e:
51
+ print(f"error: {e}", file=sys.stderr)
52
+ sys.exit(2)
53
+
54
+ try:
55
+ handler = make_handler(config.transport)
56
+ except HandlerConfigError as e:
57
+ # Exit 2, not 1: this is a configuration fault the operator must
58
+ # fix, not a transient failure worth a supervisor restart loop.
59
+ print(f"error: {e}", file=sys.stderr)
60
+ sys.exit(2)
61
+
62
+ socket_path = (
63
+ Path(args.socket_path).expanduser()
64
+ if args.socket_path
65
+ else config.transport.socket_path
66
+ )
67
+ if socket_path is None:
68
+ print("error: no socket path in config or arguments", file=sys.stderr)
69
+ sys.exit(2)
70
+
71
+ state_db = None
72
+ if args.state_db:
73
+ from ctrlrelay.core.state import StateDB
74
+ state_db = StateDB(Path(args.state_db))
75
+
76
+ server = BridgeServer(
77
+ socket_path=Path(socket_path),
78
+ handler=handler,
79
+ state_db=state_db,
80
+ )
81
+
82
+ loop = asyncio.new_event_loop()
83
+ asyncio.set_event_loop(loop)
84
+
85
+ async def _run_server() -> None:
86
+ # Wrap start() in a finally that awaits stop() so the loop cannot
87
+ # close before the handler is closed and the socket unlinked.
88
+ try:
89
+ await server.start()
90
+ finally:
91
+ await server.stop()
92
+
93
+ main_task = loop.create_task(_run_server())
94
+
95
+ def handle_signal(sig: int) -> None:
96
+ main_task.cancel()
97
+
98
+ for sig in (signal.SIGTERM, signal.SIGINT):
99
+ loop.add_signal_handler(sig, handle_signal, sig)
100
+
101
+ try:
102
+ loop.run_until_complete(main_task)
103
+ except asyncio.CancelledError:
104
+ pass
105
+ except HandlerConfigError as e:
106
+ # The server's startup preflight: same class of fault as a bad
107
+ # token above, reported the same way and with the same exit code.
108
+ print(f"error: {e}", file=sys.stderr)
109
+ sys.exit(2)
110
+ finally:
111
+ loop.close()
112
+ if state_db is not None:
113
+ try:
114
+ state_db.close()
115
+ except Exception:
116
+ pass
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()