ctrlrelay 0.11.2__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.
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/CHANGELOG.md +30 -1
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/PKG-INFO +18 -9
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/README.md +15 -8
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/config/orchestrator.yaml.example +33 -1
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/docs/bridge.md +127 -10
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/docs/configuration.md +56 -9
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/pyproject.toml +8 -1
- ctrlrelay-0.12.0/src/ctrlrelay/bridge/__init__.py +34 -0
- ctrlrelay-0.12.0/src/ctrlrelay/bridge/__main__.py +120 -0
- ctrlrelay-0.12.0/src/ctrlrelay/bridge/factory.py +112 -0
- ctrlrelay-0.12.0/src/ctrlrelay/bridge/handler.py +95 -0
- ctrlrelay-0.12.0/src/ctrlrelay/bridge/mattermost_handler.py +443 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/bridge/server.py +389 -99
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/bridge/telegram_handler.py +29 -12
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/cli.py +276 -78
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/config.py +101 -5
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/state.py +16 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/worktree.py +29 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/pipelines/__init__.py +7 -1
- ctrlrelay-0.12.0/src/ctrlrelay/pipelines/base.py +114 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/pipelines/dev.py +16 -1
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/pipelines/secops.py +25 -5
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/pipelines/task.py +9 -1
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/setup.py +51 -5
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/transports/__init__.py +11 -4
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/transports/socket_client.py +11 -1
- ctrlrelay-0.12.0/tests/conftest.py +126 -0
- ctrlrelay-0.12.0/tests/test_bridge_server.py +1769 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_cli_start.py +12 -4
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_config.py +165 -0
- ctrlrelay-0.12.0/tests/test_mattermost_handler.py +808 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_obs.py +177 -183
- ctrlrelay-0.12.0/tests/test_pipeline_base.py +528 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_setup.py +239 -0
- ctrlrelay-0.12.0/tests/test_telegram_handler.py +88 -0
- ctrlrelay-0.12.0/tests/test_transport_resolution.py +327 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_worktree.py +49 -3
- ctrlrelay-0.11.2/src/ctrlrelay/bridge/__init__.py +0 -21
- ctrlrelay-0.11.2/src/ctrlrelay/bridge/__main__.py +0 -89
- ctrlrelay-0.11.2/src/ctrlrelay/pipelines/base.py +0 -50
- ctrlrelay-0.11.2/tests/conftest.py +0 -59
- ctrlrelay-0.11.2/tests/test_bridge_server.py +0 -1067
- ctrlrelay-0.11.2/tests/test_pipeline_base.py +0 -33
- ctrlrelay-0.11.2/tests/test_telegram_handler.py +0 -45
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/.gitignore +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/LICENSE +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/SECURITY.md +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/docs/cli.md +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/docs/development.md +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/docs/index.md +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/docs/operations.md +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_archived.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.11.2 → ctrlrelay-0.12.0}/tests/test_transport.py +0 -0
|
@@ -7,6 +7,33 @@ 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
|
+
|
|
23
|
+
## [0.11.3] - 2026-09-12
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **A force-pushed default branch fails the fetch again.** v0.11.2
|
|
28
|
+
tolerated every non-fast-forward rejection so a rebased Dependabot
|
|
29
|
+
branch would stop aborting sweeps. That also covered the default
|
|
30
|
+
branch, which sessions are cut from: after a force-push to `main` on
|
|
31
|
+
GitHub, the bare repo would keep its old `main` and every session
|
|
32
|
+
would run on a tree origin no longer has, with only a log line to show
|
|
33
|
+
for it. A rejection of the default branch now raises
|
|
34
|
+
`default branch diverged from origin`; other branches are still
|
|
35
|
+
tolerated. Closes #120.
|
|
36
|
+
|
|
10
37
|
## [0.11.2] - 2026-09-10
|
|
11
38
|
|
|
12
39
|
### Fixed
|
|
@@ -1644,7 +1671,9 @@ pipeline).
|
|
|
1644
1671
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
1645
1672
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
1646
1673
|
|
|
1647
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.
|
|
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
|
|
1676
|
+
[0.11.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.2...v0.11.3
|
|
1648
1677
|
[0.11.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.1...v0.11.2
|
|
1649
1678
|
[0.11.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.0...v0.11.1
|
|
1650
1679
|
[0.11.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.10.0...v0.11.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.
|
|
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
|
|
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,
|
|
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
|
-
- **
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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)*
|
|
157
|
-
|
|
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
|
|
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,
|
|
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
|
-
- **
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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)*
|
|
118
|
-
|
|
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" #
|
|
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:
|
|
2
|
+
title: Chat bridge
|
|
3
3
|
layout: default
|
|
4
4
|
nav_order: 4
|
|
5
|
-
description: "Set up the
|
|
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
|
-
#
|
|
9
|
+
# Chat bridge
|
|
10
10
|
|
|
11
11
|
The bridge is a small daemon that:
|
|
12
12
|
|
|
13
|
-
1. Listens on a Unix socket at `
|
|
14
|
-
2. Forwards messages from ctrlrelay to a
|
|
15
|
-
3.
|
|
16
|
-
|
|
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
|
|
300
|
+
pipeline bridge chat app user
|
|
190
301
|
│ │ │ │
|
|
191
302
|
│── ask("Which?") ────>│ │ │
|
|
192
|
-
│
|
|
303
|
+
│ │─ post question ─────>│ │
|
|
193
304
|
│ │ │── push ──────>│
|
|
194
305
|
│ │ │ │
|
|
195
306
|
│ │ │<── reply ─────│
|
|
196
|
-
│
|
|
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.
|
|
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: "
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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.
|
|
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()
|