ctrlrelay 0.1.5__py3-none-any.whl
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/__init__.py +8 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +69 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +285 -0
- ctrlrelay/bridge/telegram_handler.py +117 -0
- ctrlrelay/cli.py +1449 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +291 -0
- ctrlrelay/core/dispatcher.py +202 -0
- ctrlrelay/core/github.py +272 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +319 -0
- ctrlrelay/core/pr_verifier.py +177 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +167 -0
- ctrlrelay/core/worktree.py +673 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +562 -0
- ctrlrelay/pipelines/post_merge.py +279 -0
- ctrlrelay/pipelines/secops.py +379 -0
- ctrlrelay/transports/__init__.py +33 -0
- ctrlrelay/transports/base.py +47 -0
- ctrlrelay/transports/file_mock.py +94 -0
- ctrlrelay/transports/socket_client.py +180 -0
- ctrlrelay-0.1.5.dist-info/METADATA +251 -0
- ctrlrelay-0.1.5.dist-info/RECORD +36 -0
- ctrlrelay-0.1.5.dist-info/WHEEL +4 -0
- ctrlrelay-0.1.5.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.1.5.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Transport protocol definition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TransportError(Exception):
|
|
9
|
+
"""Raised when transport operations fail."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class Transport(Protocol):
|
|
14
|
+
"""Protocol for orchestrator-to-human communication.
|
|
15
|
+
|
|
16
|
+
Implementations accept optional correlation kwargs (``session_id``,
|
|
17
|
+
``repo``, ``issue_number``) used solely for structured logging — they are
|
|
18
|
+
never part of the wire payload.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
async def send(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
session_id: str | None = None,
|
|
26
|
+
repo: str | None = None,
|
|
27
|
+
issue_number: int | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Send a one-way message (no response expected)."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
async def ask(
|
|
33
|
+
self,
|
|
34
|
+
question: str,
|
|
35
|
+
options: list[str] | None = None,
|
|
36
|
+
timeout: int = 300,
|
|
37
|
+
*,
|
|
38
|
+
session_id: str | None = None,
|
|
39
|
+
repo: str | None = None,
|
|
40
|
+
issue_number: int | None = None,
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Ask a question and wait for response."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
async def close(self) -> None:
|
|
46
|
+
"""Close the transport connection."""
|
|
47
|
+
...
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""File-based mock transport for testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ctrlrelay.core.obs import get_logger, hash_text, log_event
|
|
11
|
+
from ctrlrelay.transports.base import TransportError
|
|
12
|
+
|
|
13
|
+
_logger = get_logger("transport.file_mock")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FileMockTransport:
|
|
17
|
+
"""Transport that reads/writes to files for testing."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, inbox: Path, outbox: Path) -> None:
|
|
20
|
+
self.inbox = inbox
|
|
21
|
+
self.outbox = outbox
|
|
22
|
+
|
|
23
|
+
async def send(
|
|
24
|
+
self,
|
|
25
|
+
message: str,
|
|
26
|
+
*,
|
|
27
|
+
session_id: str | None = None,
|
|
28
|
+
repo: str | None = None,
|
|
29
|
+
issue_number: int | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Write message to outbox file."""
|
|
32
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
33
|
+
with self.outbox.open("a") as f:
|
|
34
|
+
f.write(f"[{timestamp}] {message}\n")
|
|
35
|
+
|
|
36
|
+
async def ask(
|
|
37
|
+
self,
|
|
38
|
+
question: str,
|
|
39
|
+
options: list[str] | None = None,
|
|
40
|
+
timeout: int = 300,
|
|
41
|
+
*,
|
|
42
|
+
session_id: str | None = None,
|
|
43
|
+
repo: str | None = None,
|
|
44
|
+
issue_number: int | None = None,
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Write question to outbox and poll inbox for answer."""
|
|
47
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
48
|
+
opts = f" [{'/'.join(options)}]" if options else ""
|
|
49
|
+
with self.outbox.open("a") as f:
|
|
50
|
+
f.write(f"[{timestamp}] QUESTION: {question}{opts}\n")
|
|
51
|
+
|
|
52
|
+
log_event(
|
|
53
|
+
_logger,
|
|
54
|
+
"dev.question.posted",
|
|
55
|
+
session_id=session_id,
|
|
56
|
+
repo=repo,
|
|
57
|
+
issue_number=issue_number,
|
|
58
|
+
transport="file_mock",
|
|
59
|
+
destination=str(self.outbox),
|
|
60
|
+
question=question,
|
|
61
|
+
question_length=len(question),
|
|
62
|
+
question_hash=hash_text(question),
|
|
63
|
+
options=options,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
sent_at = time.monotonic()
|
|
67
|
+
start = asyncio.get_event_loop().time()
|
|
68
|
+
while True:
|
|
69
|
+
content = self.inbox.read_text().strip()
|
|
70
|
+
if content:
|
|
71
|
+
self.inbox.write_text("")
|
|
72
|
+
answer = content.split("\n")[0].strip()
|
|
73
|
+
log_event(
|
|
74
|
+
_logger,
|
|
75
|
+
"dev.answer.received",
|
|
76
|
+
session_id=session_id,
|
|
77
|
+
repo=repo,
|
|
78
|
+
issue_number=issue_number,
|
|
79
|
+
transport="file_mock",
|
|
80
|
+
answer=answer,
|
|
81
|
+
answer_length=len(answer),
|
|
82
|
+
answer_hash=hash_text(answer),
|
|
83
|
+
elapsed_ms=int((time.monotonic() - sent_at) * 1000),
|
|
84
|
+
)
|
|
85
|
+
return answer
|
|
86
|
+
|
|
87
|
+
if asyncio.get_event_loop().time() - start > timeout:
|
|
88
|
+
raise TransportError(f"ask() timeout after {timeout}s")
|
|
89
|
+
|
|
90
|
+
await asyncio.sleep(0.5)
|
|
91
|
+
|
|
92
|
+
async def close(self) -> None:
|
|
93
|
+
"""No-op for file transport."""
|
|
94
|
+
pass
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Unix socket transport client for bridge communication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ctrlrelay.bridge.protocol import (
|
|
11
|
+
BridgeMessage,
|
|
12
|
+
BridgeOp,
|
|
13
|
+
ProtocolError,
|
|
14
|
+
parse_message,
|
|
15
|
+
serialize_message,
|
|
16
|
+
)
|
|
17
|
+
from ctrlrelay.core.obs import get_logger, hash_text, log_event
|
|
18
|
+
from ctrlrelay.transports.base import TransportError
|
|
19
|
+
|
|
20
|
+
_logger = get_logger("transport.socket")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SocketTransport:
|
|
24
|
+
"""Transport that connects to bridge via Unix socket."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, socket_path: Path) -> None:
|
|
27
|
+
self.socket_path = socket_path
|
|
28
|
+
self._reader: asyncio.StreamReader | None = None
|
|
29
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
30
|
+
self._pending: dict[str, asyncio.Future[BridgeMessage]] = {}
|
|
31
|
+
self._receive_task: asyncio.Task | None = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def connected(self) -> bool:
|
|
35
|
+
return self._writer is not None and not self._writer.is_closing()
|
|
36
|
+
|
|
37
|
+
async def connect(self) -> None:
|
|
38
|
+
"""Connect to bridge socket."""
|
|
39
|
+
try:
|
|
40
|
+
self._reader, self._writer = await asyncio.open_unix_connection(
|
|
41
|
+
str(self.socket_path)
|
|
42
|
+
)
|
|
43
|
+
self._receive_task = asyncio.create_task(self._receive_loop())
|
|
44
|
+
except (OSError, ConnectionRefusedError) as e:
|
|
45
|
+
raise TransportError(f"Failed to connect to bridge: {e}") from e
|
|
46
|
+
|
|
47
|
+
async def _receive_loop(self) -> None:
|
|
48
|
+
"""Background task to receive messages from bridge."""
|
|
49
|
+
assert self._reader is not None
|
|
50
|
+
try:
|
|
51
|
+
while True:
|
|
52
|
+
line = await self._reader.readline()
|
|
53
|
+
if not line:
|
|
54
|
+
break
|
|
55
|
+
try:
|
|
56
|
+
msg = parse_message(line.decode())
|
|
57
|
+
if msg.request_id and msg.request_id in self._pending:
|
|
58
|
+
self._pending[msg.request_id].set_result(msg)
|
|
59
|
+
except ProtocolError:
|
|
60
|
+
pass
|
|
61
|
+
except asyncio.CancelledError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
async def _send_message(self, msg: BridgeMessage) -> None:
|
|
65
|
+
"""Send message to bridge."""
|
|
66
|
+
if not self.connected:
|
|
67
|
+
raise TransportError("Transport not connected")
|
|
68
|
+
assert self._writer is not None
|
|
69
|
+
data = serialize_message(msg).encode()
|
|
70
|
+
self._writer.write(data)
|
|
71
|
+
await self._writer.drain()
|
|
72
|
+
|
|
73
|
+
async def _send_and_wait(self, msg: BridgeMessage, timeout: int) -> BridgeMessage:
|
|
74
|
+
"""Send message and wait for response."""
|
|
75
|
+
assert msg.request_id is not None
|
|
76
|
+
future: asyncio.Future[BridgeMessage] = asyncio.get_event_loop().create_future()
|
|
77
|
+
self._pending[msg.request_id] = future
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
await self._send_message(msg)
|
|
81
|
+
return await asyncio.wait_for(future, timeout=timeout)
|
|
82
|
+
except asyncio.TimeoutError as e:
|
|
83
|
+
raise TransportError("Timeout waiting for response") from e
|
|
84
|
+
finally:
|
|
85
|
+
self._pending.pop(msg.request_id, None)
|
|
86
|
+
|
|
87
|
+
async def send(
|
|
88
|
+
self,
|
|
89
|
+
message: str,
|
|
90
|
+
*,
|
|
91
|
+
session_id: str | None = None,
|
|
92
|
+
repo: str | None = None,
|
|
93
|
+
issue_number: int | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Send a one-way message."""
|
|
96
|
+
request_id = f"r-{uuid.uuid4().hex[:8]}"
|
|
97
|
+
msg = BridgeMessage(
|
|
98
|
+
op=BridgeOp.SEND,
|
|
99
|
+
request_id=request_id,
|
|
100
|
+
text=message,
|
|
101
|
+
session_id=session_id,
|
|
102
|
+
repo=repo,
|
|
103
|
+
issue_number=issue_number,
|
|
104
|
+
)
|
|
105
|
+
await self._send_message(msg)
|
|
106
|
+
|
|
107
|
+
async def ask(
|
|
108
|
+
self,
|
|
109
|
+
question: str,
|
|
110
|
+
options: list[str] | None = None,
|
|
111
|
+
timeout: int = 300,
|
|
112
|
+
*,
|
|
113
|
+
session_id: str | None = None,
|
|
114
|
+
repo: str | None = None,
|
|
115
|
+
issue_number: int | None = None,
|
|
116
|
+
) -> str:
|
|
117
|
+
"""Ask a question and wait for response."""
|
|
118
|
+
request_id = f"r-{uuid.uuid4().hex[:8]}"
|
|
119
|
+
msg = BridgeMessage(
|
|
120
|
+
op=BridgeOp.ASK,
|
|
121
|
+
request_id=request_id,
|
|
122
|
+
question=question,
|
|
123
|
+
options=options,
|
|
124
|
+
timeout=timeout,
|
|
125
|
+
session_id=session_id,
|
|
126
|
+
repo=repo,
|
|
127
|
+
issue_number=issue_number,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
log_event(
|
|
131
|
+
_logger,
|
|
132
|
+
"dev.question.posted",
|
|
133
|
+
session_id=session_id,
|
|
134
|
+
repo=repo,
|
|
135
|
+
issue_number=issue_number,
|
|
136
|
+
transport="socket",
|
|
137
|
+
destination=str(self.socket_path),
|
|
138
|
+
request_id=request_id,
|
|
139
|
+
question=question,
|
|
140
|
+
question_length=len(question),
|
|
141
|
+
question_hash=hash_text(question),
|
|
142
|
+
options=options,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
sent_at = time.monotonic()
|
|
146
|
+
response = await self._send_and_wait(msg, timeout)
|
|
147
|
+
|
|
148
|
+
if response.op == BridgeOp.ERROR:
|
|
149
|
+
raise TransportError(f"Bridge error: {response.message}")
|
|
150
|
+
if response.op == BridgeOp.ANSWER and response.answer:
|
|
151
|
+
log_event(
|
|
152
|
+
_logger,
|
|
153
|
+
"dev.answer.received",
|
|
154
|
+
session_id=session_id,
|
|
155
|
+
repo=repo,
|
|
156
|
+
issue_number=issue_number,
|
|
157
|
+
transport="socket",
|
|
158
|
+
request_id=request_id,
|
|
159
|
+
answer=response.answer,
|
|
160
|
+
answer_length=len(response.answer),
|
|
161
|
+
answer_hash=hash_text(response.answer),
|
|
162
|
+
elapsed_ms=int((time.monotonic() - sent_at) * 1000),
|
|
163
|
+
)
|
|
164
|
+
return response.answer
|
|
165
|
+
|
|
166
|
+
raise TransportError(f"Unexpected response: {response.op}")
|
|
167
|
+
|
|
168
|
+
async def close(self) -> None:
|
|
169
|
+
"""Close the connection."""
|
|
170
|
+
if self._receive_task:
|
|
171
|
+
self._receive_task.cancel()
|
|
172
|
+
try:
|
|
173
|
+
await self._receive_task
|
|
174
|
+
except asyncio.CancelledError:
|
|
175
|
+
pass
|
|
176
|
+
if self._writer:
|
|
177
|
+
self._writer.close()
|
|
178
|
+
await self._writer.wait_closed()
|
|
179
|
+
self._reader = None
|
|
180
|
+
self._writer = None
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ctrlrelay
|
|
3
|
+
Version: 0.1.5
|
|
4
|
+
Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
|
|
5
|
+
Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
|
|
6
|
+
Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
|
|
7
|
+
Project-URL: Repository, https://github.com/AInvirion/ctrlrelay
|
|
8
|
+
Project-URL: Issues, https://github.com/AInvirion/ctrlrelay/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/AInvirion/ctrlrelay/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Oscar Valenzuela <oscar.valenzuela.b@gmail.com>
|
|
11
|
+
License-Expression: Apache-2.0
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: automation,claude,github,orchestrator
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
18
|
+
Classifier: Operating System :: MacOS
|
|
19
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
24
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
25
|
+
Classifier: Topic :: System :: Systems Administration
|
|
26
|
+
Requires-Python: >=3.12
|
|
27
|
+
Requires-Dist: apscheduler>=3.10.0
|
|
28
|
+
Requires-Dist: pydantic>=2.0.0
|
|
29
|
+
Requires-Dist: python-telegram-bot>=21.0
|
|
30
|
+
Requires-Dist: pyyaml>=6.0.0
|
|
31
|
+
Requires-Dist: rich>=13.0.0
|
|
32
|
+
Requires-Dist: typer>=0.12.0
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
37
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# ctrlrelay
|
|
41
|
+
|
|
42
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
43
|
+
[](https://github.com/AInvirion/ctrlrelay/actions/workflows/test.yml)
|
|
44
|
+
[](https://github.com/AInvirion/ctrlrelay/actions/workflows/build.yml)
|
|
45
|
+
[](https://pypi.org/project/ctrlrelay/)
|
|
46
|
+
[](https://github.com/AInvirion/ctrlrelay/issues)
|
|
47
|
+
[](https://github.com/AInvirion/ctrlrelay/pulls)
|
|
48
|
+
|
|
49
|
+
> Local-first orchestrator for headless coding agents across your GitHub
|
|
50
|
+
> 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.
|
|
52
|
+
|
|
53
|
+
## Table of Contents
|
|
54
|
+
|
|
55
|
+
- [About](#about)
|
|
56
|
+
- [Features](#features)
|
|
57
|
+
- [How it works](#how-it-works)
|
|
58
|
+
- [Getting started](#getting-started)
|
|
59
|
+
- [Prerequisites](#prerequisites)
|
|
60
|
+
- [Installation](#installation)
|
|
61
|
+
- [Quick start](#quick-start)
|
|
62
|
+
- [Documentation](#documentation)
|
|
63
|
+
- [Roadmap](#roadmap)
|
|
64
|
+
- [Contributing](#contributing)
|
|
65
|
+
- [Security](#security)
|
|
66
|
+
- [License](#license)
|
|
67
|
+
|
|
68
|
+
## About
|
|
69
|
+
|
|
70
|
+
Headless coding agents are great interactively. Running them across half
|
|
71
|
+
a dozen repos without staring at a terminal is a different problem: who
|
|
72
|
+
schedules the runs, who watches for new work, who hands you the "I'm
|
|
73
|
+
blocked, what do you want?" question, who tracks the PR until it merges.
|
|
74
|
+
|
|
75
|
+
`ctrlrelay` is a small daemon that does all of that on your laptop. It
|
|
76
|
+
is local-first on purpose: no server, no queue, no multi-tenant anything.
|
|
77
|
+
Your agent credentials, your GitHub credentials, your repos, your
|
|
78
|
+
machine.
|
|
79
|
+
|
|
80
|
+
Today `ctrlrelay` ships with a Claude Code (`claude -p`) backend. The
|
|
81
|
+
orchestrator layer — worktrees, state DB, scheduler, Telegram bridge —
|
|
82
|
+
is agent-agnostic, and plug-in backends for other headless coding agents
|
|
83
|
+
are on the roadmap (see [Roadmap](#roadmap)).
|
|
84
|
+
|
|
85
|
+
## Features
|
|
86
|
+
|
|
87
|
+
- **Issue poller.** Detects issues assigned to you across every
|
|
88
|
+
configured repo, spawns a dev session in a dedicated git worktree,
|
|
89
|
+
and opens a PR.
|
|
90
|
+
- **Telegram bridge.** When a session hits a blocking question, the
|
|
91
|
+
bridge relays it to you as a DM and resumes the session once you
|
|
92
|
+
reply.
|
|
93
|
+
- **PR watcher.** Tracks the opened PR to merge and closes the loop
|
|
94
|
+
with a Telegram notification.
|
|
95
|
+
- **In-process scheduler** (APScheduler). Runs periodic jobs inside
|
|
96
|
+
the poller daemon. Ships with a `secops` job that reviews Dependabot
|
|
97
|
+
alerts and PRs daily at 6am; cron expressions follow standard Vixie
|
|
98
|
+
5-field semantics (Sun=0, DOM-OR-DOW).
|
|
99
|
+
- **Checkpoint protocol.** The agent writes a structured state file
|
|
100
|
+
at the end of every session so the orchestrator knows whether it
|
|
101
|
+
succeeded, failed, or is blocked on input. Agent backends implement
|
|
102
|
+
this protocol to integrate.
|
|
103
|
+
- **Cross-platform supervision.** launchd (macOS) and systemd (Linux)
|
|
104
|
+
examples in `docs/operations.md`. One codebase, identical behavior.
|
|
105
|
+
|
|
106
|
+
## How it works
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
┌───────────────┐
|
|
110
|
+
│ GitHub API │
|
|
111
|
+
└───────┬───────┘
|
|
112
|
+
│ (poll: issues assigned to me)
|
|
113
|
+
┌───────▼───────┐ ┌──────────────┐
|
|
114
|
+
│ poller daemon │◄────────┤ APScheduler│
|
|
115
|
+
│ (launchd / │ │ (secops cron)│
|
|
116
|
+
│ systemd) │ └──────────────┘
|
|
117
|
+
└───┬───────┬───┘
|
|
118
|
+
new issue │ │ blocked session
|
|
119
|
+
│ │
|
|
120
|
+
┌──────────▼──┐ ┌─▼───────────────────┐
|
|
121
|
+
│ dev pipeline│ │ Telegram bridge │
|
|
122
|
+
│ in worktree │ │ (socket ↔ bot API) │
|
|
123
|
+
│ agent CLI │ └──────────┬──────────┘
|
|
124
|
+
└──────┬──────┘ │
|
|
125
|
+
│ PR opened │ DM you
|
|
126
|
+
▼ ▼
|
|
127
|
+
┌────────────┐ ┌─────────┐
|
|
128
|
+
│ PR watcher │ │ You │
|
|
129
|
+
└────────────┘ └─────────┘
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Under the hood it's Python + `asyncio` + `sqlite` for state. The agent
|
|
133
|
+
is invoked as a subprocess (today: `claude -p`), and GitHub is accessed
|
|
134
|
+
via the `gh` CLI. No web server, no queue, no database dependency — just
|
|
135
|
+
a launchd/systemd-supervised daemon.
|
|
136
|
+
|
|
137
|
+
## Getting started
|
|
138
|
+
|
|
139
|
+
### Prerequisites
|
|
140
|
+
|
|
141
|
+
- **Python 3.12+**
|
|
142
|
+
- **git 2.20+**
|
|
143
|
+
- The **[`gh` CLI][gh-cli]**, authenticated (`gh auth login`) — used for
|
|
144
|
+
all GitHub API calls.
|
|
145
|
+
- A **headless coding agent backend.** Today that means the
|
|
146
|
+
**[`claude` CLI][claude-cli]**, authenticated (`claude auth login`).
|
|
147
|
+
Future backends will document their own setup.
|
|
148
|
+
- *(Optional, for the `secops` pipeline)* the **[`codex` CLI][codex-cli]**,
|
|
149
|
+
authenticated. The secops pipeline invokes `codex review` as an
|
|
150
|
+
independent reviewer for the agent's output; you can disable it by
|
|
151
|
+
setting `code_review.method: "none"` in your config if you prefer to
|
|
152
|
+
skip the review step.
|
|
153
|
+
- *(Optional)* a Telegram bot token if you want the bridge — see
|
|
154
|
+
[Telegram bridge docs][docs-bridge].
|
|
155
|
+
|
|
156
|
+
### Installation
|
|
157
|
+
|
|
158
|
+
**From PyPI** (once published):
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
pip install ctrlrelay
|
|
162
|
+
# or: uv pip install ctrlrelay
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**From source** (current path while in alpha):
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
git clone https://github.com/AInvirion/ctrlrelay.git
|
|
169
|
+
cd ctrlrelay
|
|
170
|
+
uv pip install -e . # or: pip install -e .
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Quick start
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
# Copy and edit the example config:
|
|
177
|
+
cp config/orchestrator.yaml.example config/orchestrator.yaml
|
|
178
|
+
|
|
179
|
+
# Validate it:
|
|
180
|
+
ctrlrelay config validate
|
|
181
|
+
|
|
182
|
+
# Run the dev pipeline against an issue you're assigned:
|
|
183
|
+
ctrlrelay run dev --issue 42 --repo your-org/your-repo
|
|
184
|
+
|
|
185
|
+
# Or start the poller to auto-process newly assigned issues + run the
|
|
186
|
+
# scheduled secops sweep daily at 6am:
|
|
187
|
+
ctrlrelay poller start # daemonizes; returns the terminal
|
|
188
|
+
ctrlrelay poller status # verify it's running
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Run as a supervised daemon (launchd on macOS / systemd on Linux) — see
|
|
192
|
+
[Operations][ops-docs].
|
|
193
|
+
|
|
194
|
+
## Documentation
|
|
195
|
+
|
|
196
|
+
- [Getting started][docs-start]
|
|
197
|
+
- [Configuration][docs-config]
|
|
198
|
+
- [Telegram bridge][docs-bridge]
|
|
199
|
+
- [Feedback loop][docs-feedback]
|
|
200
|
+
- [CLI reference][docs-cli]
|
|
201
|
+
- [Operations (launchd / systemd / scheduled jobs)][ops-docs]
|
|
202
|
+
- [Architecture][docs-arch]
|
|
203
|
+
- [Development][docs-dev]
|
|
204
|
+
|
|
205
|
+
## Roadmap
|
|
206
|
+
|
|
207
|
+
- **Multi-agent backend support.** The agent dispatcher is the seam we
|
|
208
|
+
intend to widen so `ctrlrelay` can drive alternative headless coding
|
|
209
|
+
agents (e.g. OpenAI Codex CLI, OpenCode, Hermes) alongside Claude
|
|
210
|
+
Code. Each backend will implement the same checkpoint protocol and
|
|
211
|
+
be selectable per repo via config.
|
|
212
|
+
- **Additional scheduled jobs.** The in-process scheduler already has
|
|
213
|
+
`secops`; follow-ups include a weekly activity summary and a stale-
|
|
214
|
+
session reaper.
|
|
215
|
+
- **Dashboard mode.** An optional, opt-in heartbeat push to a hosted
|
|
216
|
+
dashboard for operators running the daemon across many machines.
|
|
217
|
+
|
|
218
|
+
## Contributing
|
|
219
|
+
|
|
220
|
+
We welcome contributions from the community! Please read our
|
|
221
|
+
[Contributing Guidelines](CONTRIBUTING.md) before submitting a pull
|
|
222
|
+
request, and abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
|
|
223
|
+
|
|
224
|
+
First-time contributors will be prompted by the CLA Assistant bot to
|
|
225
|
+
sign the Contributor Assignment Agreement in-PR — it's a one-time,
|
|
226
|
+
one-comment step.
|
|
227
|
+
|
|
228
|
+
## Security
|
|
229
|
+
|
|
230
|
+
If you discover a security vulnerability, please follow our
|
|
231
|
+
[Security Policy](SECURITY.md). Please do not file public GitHub issues
|
|
232
|
+
for security reports — open a private advisory instead.
|
|
233
|
+
|
|
234
|
+
## License
|
|
235
|
+
|
|
236
|
+
This project is licensed under the Apache License 2.0 — see the
|
|
237
|
+
[LICENSE](LICENSE) file for details.
|
|
238
|
+
|
|
239
|
+
Copyright (c) 2026 AInvirion LLC. All Rights Reserved.
|
|
240
|
+
|
|
241
|
+
[claude-cli]: https://docs.anthropic.com/claude/docs/claude-cli
|
|
242
|
+
[gh-cli]: https://cli.github.com/
|
|
243
|
+
[codex-cli]: https://github.com/openai/codex
|
|
244
|
+
[docs-start]: https://ainvirion.github.io/ctrlrelay/getting-started/
|
|
245
|
+
[docs-config]: https://ainvirion.github.io/ctrlrelay/configuration/
|
|
246
|
+
[docs-bridge]: https://ainvirion.github.io/ctrlrelay/bridge/
|
|
247
|
+
[docs-feedback]: https://ainvirion.github.io/ctrlrelay/feedback-loop/
|
|
248
|
+
[docs-cli]: https://ainvirion.github.io/ctrlrelay/cli/
|
|
249
|
+
[ops-docs]: https://ainvirion.github.io/ctrlrelay/operations/
|
|
250
|
+
[docs-arch]: https://ainvirion.github.io/ctrlrelay/architecture/
|
|
251
|
+
[docs-dev]: https://ainvirion.github.io/ctrlrelay/development/
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
ctrlrelay/__init__.py,sha256=fFGk3vk282vfddBtZCBXvCV9bc5cEWu3QMgXEfkq16c,175
|
|
2
|
+
ctrlrelay/cli.py,sha256=EWpqSiAfcfqcWVSqefDfph5uk301aSYUU3LTQFOgXmo,51108
|
|
3
|
+
ctrlrelay/bridge/__init__.py,sha256=_xSaxU_IGHMBmdl6h3VX5PRgsI6DcGu-9COdIF_6LX0,459
|
|
4
|
+
ctrlrelay/bridge/__main__.py,sha256=87DXn2Z6Wud6aA1-7NnlMolgZ99yKtWAjGJzl4kqR3w,1833
|
|
5
|
+
ctrlrelay/bridge/protocol.py,sha256=9zOZp6DjIgke4JJRxc3YVDR725mbHA5-S3rGaQ33A9s,1695
|
|
6
|
+
ctrlrelay/bridge/server.py,sha256=2-JCl-btyAxubPtkbHt2mJVbdNYa_CaKyMMMX6T1T_g,10211
|
|
7
|
+
ctrlrelay/bridge/telegram_handler.py,sha256=YgcTO8osz5_BpzLm2JU6rneVHjpo1eSRLUm6pzpU__k,4121
|
|
8
|
+
ctrlrelay/core/__init__.py,sha256=aTEJzxTqZnwuTcZotlx0HbzBOh3SqoRyeVa3r5Sa8wY,1193
|
|
9
|
+
ctrlrelay/core/audit.py,sha256=Sa0QCZFgJ9bd1uSZKDHfpy_6B63SxreWDlc3iYijn8o,7068
|
|
10
|
+
ctrlrelay/core/checkpoint.py,sha256=jN-UHq6pboe-LkUN20X-VCNnbuHtSnmkYjASVag87Cc,4512
|
|
11
|
+
ctrlrelay/core/config.py,sha256=6Lnuy99x-RLtTlEZXDy7AmPmU8G5K3404OyO1eMup0c,9228
|
|
12
|
+
ctrlrelay/core/dispatcher.py,sha256=ggDOArLnh7pfYFsSh85tvgJ0jKSMfDpysAiQFdZcm-E,6482
|
|
13
|
+
ctrlrelay/core/github.py,sha256=Nf0n1elhqCoJjjYCcknnW6oGxalq6yhih0j0A9-BDFA,8370
|
|
14
|
+
ctrlrelay/core/obs.py,sha256=HuqqFXe-ngxg68bMrvY4GpWTCucOjr7Qz0kpGCrpVmE,3361
|
|
15
|
+
ctrlrelay/core/poller.py,sha256=DWjHMKeMeyjKgJ_MrQ1K1GgNyDIFqzBAnnQJzz0kpZk,13037
|
|
16
|
+
ctrlrelay/core/pr_verifier.py,sha256=SlOMXCrwHfzmJCeisKbm4_q4P9FhdcOpRsWUEtsSOI8,7351
|
|
17
|
+
ctrlrelay/core/pr_watcher.py,sha256=dsVSZ-7wnLnIyH5-sRpBkz7ExSqZn7spSGJKb90N0vE,4482
|
|
18
|
+
ctrlrelay/core/scheduler.py,sha256=AoUPQ9u4YpWTD0FsROCpP65qtgu4mxiiItJtTmArgt4,13530
|
|
19
|
+
ctrlrelay/core/state.py,sha256=PNcp5iIKwnkQyKMO4mjj-cVys0qmABr93lJTzj1RT7k,4681
|
|
20
|
+
ctrlrelay/core/worktree.py,sha256=Ovn3aP4qVY7DT_4s199VHZhzGl0ZBTS1SKZYUDlL1nw,27495
|
|
21
|
+
ctrlrelay/dashboard/__init__.py,sha256=aJtDFoW-aW7kggFfC5DCydk9QFLB2GPjaKDfcN9upBM,193
|
|
22
|
+
ctrlrelay/dashboard/client.py,sha256=b7_jKy_hXTlhECvo-ZVnOwLs-6Pg73zzFLUO-3DzOmg,5015
|
|
23
|
+
ctrlrelay/pipelines/__init__.py,sha256=0SwQ9d_XPTODMiiJAd2fq9B5Ya0RvSZt_iQZjHAuFUY,419
|
|
24
|
+
ctrlrelay/pipelines/base.py,sha256=PJZCrA4M6sErGp5iud4K4nts3hmyU-B2ddoutXUmHU0,1144
|
|
25
|
+
ctrlrelay/pipelines/dev.py,sha256=5wGHZjYaXiu0OrL1G4bi75H0kI0cexHDYXNWQMGRmq0,20981
|
|
26
|
+
ctrlrelay/pipelines/post_merge.py,sha256=Ws97Tz-y7B0pX6JXq0pBmm2mX3q0-jJH0RZNQTwQ5f0,10917
|
|
27
|
+
ctrlrelay/pipelines/secops.py,sha256=wp2nLRg42LYETt7BvEqpEILjBEjqCQG9EV0FQ7Cn5tQ,14124
|
|
28
|
+
ctrlrelay/transports/__init__.py,sha256=W_1GeL8LzKbyK6qLOIHSc_aw2Iohj8nGlTCyxOWXz8Q,1020
|
|
29
|
+
ctrlrelay/transports/base.py,sha256=cjbh1UhBXjBEbnKwlS3zJXi2R3HOx0W00CH2y1tHOoM,1199
|
|
30
|
+
ctrlrelay/transports/file_mock.py,sha256=FME-k7DyszSto6IpjuPC5tsCA_bJiejQ0lSY_8yOyr4,2960
|
|
31
|
+
ctrlrelay/transports/socket_client.py,sha256=YvBaXJt-Pjkcj3ayHU8NFTyUap50sKdVUwqrGwt2G-s,5975
|
|
32
|
+
ctrlrelay-0.1.5.dist-info/METADATA,sha256=hRERHF5v14gfjUDlDBlVCUqMmpM9Fq7DFsMH3Lfr5Jc,10682
|
|
33
|
+
ctrlrelay-0.1.5.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
34
|
+
ctrlrelay-0.1.5.dist-info/entry_points.txt,sha256=OfNfO9p6j82rKledRFMzTntWWhgIbARE23OEd6UCDdU,48
|
|
35
|
+
ctrlrelay-0.1.5.dist-info/licenses/LICENSE,sha256=rUu0YtwfchA4Zv96xvsR0cGxmARpqaeJOWQae5a24oA,11339
|
|
36
|
+
ctrlrelay-0.1.5.dist-info/RECORD,,
|