codex-telegram-bridge 0.5.3__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.
@@ -0,0 +1,17 @@
1
+ """Shared runtime contracts for Telegram-controlled Codex sessions."""
2
+
3
+ from .approvals import ApprovalOption, ApprovalRequest
4
+ from .capabilities import CapabilityRegistry, HeadCapabilities
5
+ from .locks import WorkdirLock, WorkdirLockError
6
+ from .types import AgentEvent, AgentMessage
7
+
8
+ __all__ = [
9
+ "AgentEvent",
10
+ "AgentMessage",
11
+ "ApprovalOption",
12
+ "ApprovalRequest",
13
+ "CapabilityRegistry",
14
+ "HeadCapabilities",
15
+ "WorkdirLock",
16
+ "WorkdirLockError",
17
+ ]
@@ -0,0 +1,6 @@
1
+ """Head adapter implementations."""
2
+
3
+ from .base import HeadAdapter
4
+ from .codex_repl import CodexReplAdapter
5
+
6
+ __all__ = ["CodexReplAdapter", "HeadAdapter"]
@@ -0,0 +1,32 @@
1
+ """Protocol implemented by Codex runtime heads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import Protocol
7
+
8
+ from agent_runtime.approvals import ApprovalOption
9
+ from agent_runtime.capabilities import HeadCapabilities
10
+ from agent_runtime.types import AgentEvent, AgentMessage
11
+
12
+
13
+ class HeadAdapter(Protocol):
14
+ name: str
15
+
16
+ def spawn(self) -> None:
17
+ raise NotImplementedError
18
+
19
+ def send(self, message: AgentMessage) -> None:
20
+ raise NotImplementedError
21
+
22
+ def recv(self) -> Iterable[AgentEvent]:
23
+ raise NotImplementedError
24
+
25
+ def inject_approval(self, choice: ApprovalOption | str) -> None:
26
+ raise NotImplementedError
27
+
28
+ def kill(self) -> None:
29
+ raise NotImplementedError
30
+
31
+ def capabilities(self) -> HeadCapabilities:
32
+ raise NotImplementedError
@@ -0,0 +1,109 @@
1
+ """Adapter for the visible Codex REPL controlled through tmux."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from agent_runtime.approvals import ApprovalOption
11
+ from agent_runtime.capabilities import HeadCapabilities
12
+ from agent_runtime.types import AgentEvent, AgentMessage
13
+
14
+
15
+ class CodexReplAdapter:
16
+ name = "codex_repl"
17
+
18
+ def __init__(self, repl: Any, workdir: Path | None = None) -> None:
19
+ self.repl = repl
20
+ self.workdir = workdir
21
+ self._session_path: Path | None = None
22
+ self._session_pos = 0
23
+
24
+ def spawn(self) -> None:
25
+ self.repl.verify()
26
+
27
+ def send(self, message: AgentMessage) -> None:
28
+ self.repl.paste_prompt(message.text)
29
+
30
+ def recv(self) -> Iterable[AgentEvent]:
31
+ path = self.session_file()
32
+ if path != self._session_path:
33
+ self._session_path = path
34
+ self._session_pos = 0
35
+
36
+ events: list[AgentEvent] = []
37
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
38
+ handle.seek(self._session_pos)
39
+ while True:
40
+ line = handle.readline()
41
+ if not line:
42
+ break
43
+ self._session_pos = handle.tell()
44
+ event = self._event_from_json_line(line)
45
+ if event:
46
+ events.append(event)
47
+ return events
48
+
49
+ def inject_approval(self, choice: ApprovalOption | str) -> None:
50
+ key = choice.key if isinstance(choice, ApprovalOption) else str(choice)
51
+ self.repl.send_approval_key(key)
52
+
53
+ def kill(self) -> None:
54
+ return None
55
+
56
+ def capabilities(self) -> HeadCapabilities:
57
+ roots = (self.workdir,) if self.workdir else ()
58
+ return HeadCapabilities(
59
+ head=self.name,
60
+ vision=True,
61
+ audio=True,
62
+ video=True,
63
+ repl=True,
64
+ approval=True,
65
+ streaming=True,
66
+ workdir_access=roots,
67
+ notes=("visible Codex TUI via tmux", "final answers read from Codex JSONL"),
68
+ )
69
+
70
+ def capture_pane(self, lines: int = 80) -> str:
71
+ return self.repl.capture_pane(lines)
72
+
73
+ def clear_composer(self) -> None:
74
+ self.repl.clear_composer()
75
+
76
+ def session_file(self) -> Path:
77
+ return self.repl.session_file()
78
+
79
+ def _event_from_json_line(self, line: str) -> AgentEvent | None:
80
+ try:
81
+ record = json.loads(line)
82
+ except json.JSONDecodeError:
83
+ return None
84
+ if not isinstance(record, dict):
85
+ return None
86
+
87
+ kind = record.get("type")
88
+ payload = record.get("payload") if isinstance(record.get("payload"), dict) else {}
89
+ if kind == "event_msg":
90
+ payload_type = payload.get("type")
91
+ if payload_type == "user_message":
92
+ return AgentEvent("user", str(payload.get("message") or ""), source_head=self.name)
93
+ if payload_type == "agent_message" and payload.get("phase") == "final_answer":
94
+ return AgentEvent("assistant", str(payload.get("message") or ""), source_head=self.name)
95
+
96
+ if kind == "response_item":
97
+ if payload.get("type") == "message" and payload.get("role") == "assistant":
98
+ phase = payload.get("phase") or payload.get("metadata", {}).get("phase")
99
+ if phase != "final_answer":
100
+ return None
101
+ content = payload.get("content")
102
+ if isinstance(content, list):
103
+ parts = [
104
+ str(item.get("text") or "")
105
+ for item in content
106
+ if isinstance(item, dict) and item.get("type") == "output_text"
107
+ ]
108
+ return AgentEvent("assistant", "\n".join(parts), source_head=self.name)
109
+ return None
@@ -0,0 +1,87 @@
1
+ """Approval request model with expiry and cancellation state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass, replace
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ApprovalOption:
11
+ number: str
12
+ label: str
13
+ key: str
14
+
15
+ @property
16
+ def short_label(self) -> str:
17
+ label = self.label.strip()
18
+ lowered = label.lower()
19
+ if "don't ask" in lowered or "do not ask" in lowered:
20
+ return "Yes, don't ask again"
21
+ if lowered.startswith("no"):
22
+ return "No"
23
+ if lowered.startswith("yes"):
24
+ return "Yes"
25
+ return label[:40] or self.number
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ApprovalRequest:
30
+ approval_id: str
31
+ source_head: str
32
+ command: str
33
+ reason: str
34
+ options: tuple[ApprovalOption, ...]
35
+ expires_at: float
36
+ cancelled: bool = False
37
+
38
+ @classmethod
39
+ def create(
40
+ cls,
41
+ approval_id: str,
42
+ source_head: str,
43
+ command: str,
44
+ reason: str,
45
+ options: tuple[ApprovalOption, ...],
46
+ ttl_seconds: int,
47
+ now: float | None = None,
48
+ ) -> "ApprovalRequest":
49
+ base = time.time() if now is None else now
50
+ return cls(
51
+ approval_id=approval_id,
52
+ source_head=source_head,
53
+ command=command,
54
+ reason=reason,
55
+ options=options,
56
+ expires_at=base + max(1, ttl_seconds),
57
+ )
58
+
59
+ @property
60
+ def signature(self) -> str:
61
+ return self.approval_id
62
+
63
+ @property
64
+ def short_signature(self) -> str:
65
+ return self.approval_id[:16]
66
+
67
+ def is_expired(self, now: float | None = None) -> bool:
68
+ current = time.time() if now is None else now
69
+ return current >= self.expires_at
70
+
71
+ def is_active(self, now: float | None = None) -> bool:
72
+ return not self.cancelled and not self.is_expired(now)
73
+
74
+ def cancel(self) -> "ApprovalRequest":
75
+ return replace(self, cancelled=True)
76
+
77
+ def option(self, choice: str) -> ApprovalOption | None:
78
+ return next((item for item in self.options if item.number == choice), None)
79
+
80
+ def telegram_text(self) -> str:
81
+ lines = [f"{self.source_head} is waiting for command approval.", ""]
82
+ if self.command:
83
+ lines.extend(["Command:", self.command[:600], ""])
84
+ if self.reason:
85
+ lines.extend(["Reason:", self.reason[:600], ""])
86
+ lines.append("Choose a button, or reply with the visible number/shortcut.")
87
+ return "\n".join(lines)
@@ -0,0 +1,59 @@
1
+ """Capability declarations for Codex runtime heads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class HeadCapabilities:
11
+ head: str
12
+ vision: bool = False
13
+ audio: bool = False
14
+ video: bool = False
15
+ repl: bool = False
16
+ approval: bool = False
17
+ streaming: bool = False
18
+ workdir_access: tuple[Path, ...] = ()
19
+ notes: tuple[str, ...] = ()
20
+
21
+ def as_dict(self) -> dict[str, object]:
22
+ return {
23
+ "head": self.head,
24
+ "vision": self.vision,
25
+ "audio": self.audio,
26
+ "video": self.video,
27
+ "repl": self.repl,
28
+ "approval": self.approval,
29
+ "streaming": self.streaming,
30
+ "workdir_access": [str(path) for path in self.workdir_access],
31
+ "notes": list(self.notes),
32
+ }
33
+
34
+
35
+ @dataclass
36
+ class CapabilityRegistry:
37
+ _heads: dict[str, HeadCapabilities] = field(default_factory=dict)
38
+
39
+ def register(self, capabilities: HeadCapabilities) -> None:
40
+ if not capabilities.head:
41
+ raise ValueError("head capability name is required")
42
+ self._heads[capabilities.head] = capabilities
43
+
44
+ def get(self, head: str) -> HeadCapabilities | None:
45
+ return self._heads.get(head)
46
+
47
+ def require(self, head: str) -> HeadCapabilities:
48
+ capabilities = self.get(head)
49
+ if capabilities is None:
50
+ raise KeyError(f"unknown head: {head}")
51
+ return capabilities
52
+
53
+ def supports(self, head: str, capability: str) -> bool:
54
+ capabilities = self.require(head)
55
+ value = getattr(capabilities, capability, None)
56
+ return bool(value)
57
+
58
+ def as_dict(self) -> dict[str, dict[str, object]]:
59
+ return {head: caps.as_dict() for head, caps in sorted(self._heads.items())}
agent_runtime/locks.py ADDED
@@ -0,0 +1,111 @@
1
+ """Workdir lock primitive for multi-head control planes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ import hashlib
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+
13
+ class WorkdirLockError(RuntimeError):
14
+ pass
15
+
16
+
17
+ def _process_alive(pid: int) -> bool:
18
+ if pid <= 0:
19
+ return False
20
+ try:
21
+ os.kill(pid, 0)
22
+ except ProcessLookupError:
23
+ return False
24
+ except PermissionError:
25
+ return True
26
+ return True
27
+
28
+
29
+ def _lock_name(workdir: Path) -> str:
30
+ resolved = str(workdir.expanduser().resolve())
31
+ digest = hashlib.sha1(resolved.encode("utf-8", errors="replace")).hexdigest()[:16]
32
+ basename = workdir.name or "workdir"
33
+ safe_base = "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "-" for ch in basename)
34
+ return f"{safe_base[:80]}-{digest}"
35
+
36
+
37
+ @dataclass
38
+ class WorkdirLock:
39
+ workdir: Path
40
+ state_dir: Path
41
+ owner: str
42
+ stale_seconds: int = 24 * 60 * 60
43
+
44
+ def __post_init__(self) -> None:
45
+ self.workdir = self.workdir.expanduser().resolve()
46
+ self.state_dir = self.state_dir.expanduser().resolve()
47
+ self.lock_file = self.state_dir / "workdir-locks" / f"{_lock_name(self.workdir)}.lock"
48
+ self.acquired = False
49
+
50
+ def _payload(self) -> dict[str, object]:
51
+ return {
52
+ "owner": self.owner,
53
+ "pid": os.getpid(),
54
+ "workdir": str(self.workdir),
55
+ "created_at": time.time(),
56
+ }
57
+
58
+ def _existing_payload(self) -> dict[str, object]:
59
+ try:
60
+ return json.loads(self.lock_file.read_text(encoding="utf-8"))
61
+ except (OSError, json.JSONDecodeError):
62
+ return {}
63
+
64
+ def _remove_stale_lock_if_possible(self) -> bool:
65
+ payload = self._existing_payload()
66
+ pid = int(payload.get("pid") or 0)
67
+ created_at = float(payload.get("created_at") or 0)
68
+ if pid and _process_alive(pid):
69
+ return False
70
+ if created_at and time.time() - created_at < self.stale_seconds:
71
+ return False
72
+ try:
73
+ self.lock_file.unlink()
74
+ return True
75
+ except OSError:
76
+ return False
77
+
78
+ def acquire(self) -> None:
79
+ self.lock_file.parent.mkdir(parents=True, exist_ok=True)
80
+ while True:
81
+ try:
82
+ fd = os.open(str(self.lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
83
+ except FileExistsError as exc:
84
+ if self._remove_stale_lock_if_possible():
85
+ continue
86
+ payload = self._existing_payload()
87
+ raise WorkdirLockError(
88
+ f"workdir already locked by {payload.get('owner', 'unknown')}: {self.workdir}"
89
+ ) from exc
90
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
91
+ json.dump(self._payload(), handle)
92
+ self.acquired = True
93
+ return
94
+
95
+ def release(self) -> None:
96
+ if not self.acquired:
97
+ return
98
+ payload = self._existing_payload()
99
+ if int(payload.get("pid") or 0) == os.getpid() and payload.get("owner") == self.owner:
100
+ try:
101
+ self.lock_file.unlink()
102
+ except OSError:
103
+ pass
104
+ self.acquired = False
105
+
106
+ def __enter__(self) -> "WorkdirLock":
107
+ self.acquire()
108
+ return self
109
+
110
+ def __exit__(self, exc_type, exc, tb) -> None:
111
+ self.release()
@@ -0,0 +1,77 @@
1
+ """Transport abstractions for local control-plane input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import queue
7
+ import select
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Protocol
11
+
12
+
13
+ class Transport(Protocol):
14
+ def send(self, message: str) -> None:
15
+ raise NotImplementedError
16
+
17
+ def recv(self, timeout: float | None = None) -> str:
18
+ raise NotImplementedError
19
+
20
+ def close(self) -> None:
21
+ raise NotImplementedError
22
+
23
+
24
+ class QueueTransport:
25
+ def __init__(self) -> None:
26
+ self._queue: queue.Queue[str] = queue.Queue()
27
+
28
+ def send(self, message: str) -> None:
29
+ self._queue.put(message)
30
+
31
+ def recv(self, timeout: float | None = None) -> str:
32
+ return self._queue.get(timeout=timeout)
33
+
34
+ def close(self) -> None:
35
+ return None
36
+
37
+
38
+ class FifoTransport:
39
+ def __init__(self, path: Path) -> None:
40
+ self.path = path
41
+ self.path.parent.mkdir(parents=True, exist_ok=True)
42
+ if not self.path.exists():
43
+ os.mkfifo(self.path, 0o600)
44
+
45
+ def send(self, message: str) -> None:
46
+ with self.path.open("w", encoding="utf-8") as fifo:
47
+ fifo.write(message.rstrip("\n") + "\n")
48
+
49
+ def recv(self, timeout: float | None = None) -> str:
50
+ if timeout is None:
51
+ with self.path.open("r", encoding="utf-8") as fifo:
52
+ return fifo.readline().rstrip("\n")
53
+
54
+ deadline = time.monotonic() + max(0.0, timeout)
55
+ fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK)
56
+ try:
57
+ chunks: list[bytes] = []
58
+ while True:
59
+ remaining = deadline - time.monotonic()
60
+ if remaining <= 0:
61
+ raise TimeoutError(f"timed out waiting for FIFO input: {self.path}")
62
+ readable, _, _ = select.select([fd], [], [], remaining)
63
+ if not readable:
64
+ raise TimeoutError(f"timed out waiting for FIFO input: {self.path}")
65
+ data = os.read(fd, 4096)
66
+ if not data:
67
+ time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
68
+ continue
69
+ chunks.append(data)
70
+ if b"\n" in data:
71
+ break
72
+ return b"".join(chunks).split(b"\n", 1)[0].decode("utf-8", errors="replace")
73
+ finally:
74
+ os.close(fd)
75
+
76
+ def close(self) -> None:
77
+ return None
agent_runtime/types.py ADDED
@@ -0,0 +1,33 @@
1
+ """Common message and event types shared by head adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+
10
+ AgentEventKind = Literal[
11
+ "started",
12
+ "user",
13
+ "assistant",
14
+ "approval_requested",
15
+ "approval_resolved",
16
+ "error",
17
+ "stopped",
18
+ ]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class AgentMessage:
23
+ text: str
24
+ media_paths: tuple[Path, ...] = ()
25
+ metadata: dict[str, Any] = field(default_factory=dict)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class AgentEvent:
30
+ kind: AgentEventKind
31
+ text: str = ""
32
+ source_head: str = ""
33
+ metadata: dict[str, Any] = field(default_factory=dict)