codex-autorunner 0.1.0__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.
Files changed (147) hide show
  1. codex_autorunner/__init__.py +3 -0
  2. codex_autorunner/bootstrap.py +151 -0
  3. codex_autorunner/cli.py +886 -0
  4. codex_autorunner/codex_cli.py +79 -0
  5. codex_autorunner/codex_runner.py +17 -0
  6. codex_autorunner/core/__init__.py +1 -0
  7. codex_autorunner/core/about_car.py +125 -0
  8. codex_autorunner/core/codex_runner.py +100 -0
  9. codex_autorunner/core/config.py +1465 -0
  10. codex_autorunner/core/doc_chat.py +547 -0
  11. codex_autorunner/core/docs.py +37 -0
  12. codex_autorunner/core/engine.py +720 -0
  13. codex_autorunner/core/git_utils.py +206 -0
  14. codex_autorunner/core/hub.py +756 -0
  15. codex_autorunner/core/injected_context.py +9 -0
  16. codex_autorunner/core/locks.py +57 -0
  17. codex_autorunner/core/logging_utils.py +158 -0
  18. codex_autorunner/core/notifications.py +465 -0
  19. codex_autorunner/core/optional_dependencies.py +41 -0
  20. codex_autorunner/core/prompt.py +107 -0
  21. codex_autorunner/core/prompts.py +275 -0
  22. codex_autorunner/core/request_context.py +21 -0
  23. codex_autorunner/core/runner_controller.py +116 -0
  24. codex_autorunner/core/runner_process.py +29 -0
  25. codex_autorunner/core/snapshot.py +576 -0
  26. codex_autorunner/core/state.py +156 -0
  27. codex_autorunner/core/update.py +567 -0
  28. codex_autorunner/core/update_runner.py +44 -0
  29. codex_autorunner/core/usage.py +1221 -0
  30. codex_autorunner/core/utils.py +108 -0
  31. codex_autorunner/discovery.py +102 -0
  32. codex_autorunner/housekeeping.py +423 -0
  33. codex_autorunner/integrations/__init__.py +1 -0
  34. codex_autorunner/integrations/app_server/__init__.py +6 -0
  35. codex_autorunner/integrations/app_server/client.py +1386 -0
  36. codex_autorunner/integrations/app_server/supervisor.py +206 -0
  37. codex_autorunner/integrations/github/__init__.py +10 -0
  38. codex_autorunner/integrations/github/service.py +889 -0
  39. codex_autorunner/integrations/telegram/__init__.py +1 -0
  40. codex_autorunner/integrations/telegram/adapter.py +1401 -0
  41. codex_autorunner/integrations/telegram/commands_registry.py +104 -0
  42. codex_autorunner/integrations/telegram/config.py +450 -0
  43. codex_autorunner/integrations/telegram/constants.py +154 -0
  44. codex_autorunner/integrations/telegram/dispatch.py +162 -0
  45. codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
  46. codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
  47. codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
  48. codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
  49. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
  50. codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
  51. codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
  52. codex_autorunner/integrations/telegram/helpers.py +2084 -0
  53. codex_autorunner/integrations/telegram/notifications.py +164 -0
  54. codex_autorunner/integrations/telegram/outbox.py +174 -0
  55. codex_autorunner/integrations/telegram/rendering.py +102 -0
  56. codex_autorunner/integrations/telegram/retry.py +37 -0
  57. codex_autorunner/integrations/telegram/runtime.py +270 -0
  58. codex_autorunner/integrations/telegram/service.py +921 -0
  59. codex_autorunner/integrations/telegram/state.py +1223 -0
  60. codex_autorunner/integrations/telegram/transport.py +318 -0
  61. codex_autorunner/integrations/telegram/types.py +57 -0
  62. codex_autorunner/integrations/telegram/voice.py +413 -0
  63. codex_autorunner/manifest.py +150 -0
  64. codex_autorunner/routes/__init__.py +53 -0
  65. codex_autorunner/routes/base.py +470 -0
  66. codex_autorunner/routes/docs.py +275 -0
  67. codex_autorunner/routes/github.py +197 -0
  68. codex_autorunner/routes/repos.py +121 -0
  69. codex_autorunner/routes/sessions.py +137 -0
  70. codex_autorunner/routes/shared.py +137 -0
  71. codex_autorunner/routes/system.py +175 -0
  72. codex_autorunner/routes/terminal_images.py +107 -0
  73. codex_autorunner/routes/voice.py +128 -0
  74. codex_autorunner/server.py +23 -0
  75. codex_autorunner/spec_ingest.py +113 -0
  76. codex_autorunner/static/app.js +95 -0
  77. codex_autorunner/static/autoRefresh.js +209 -0
  78. codex_autorunner/static/bootstrap.js +105 -0
  79. codex_autorunner/static/bus.js +23 -0
  80. codex_autorunner/static/cache.js +52 -0
  81. codex_autorunner/static/constants.js +48 -0
  82. codex_autorunner/static/dashboard.js +795 -0
  83. codex_autorunner/static/docs.js +1514 -0
  84. codex_autorunner/static/env.js +99 -0
  85. codex_autorunner/static/github.js +168 -0
  86. codex_autorunner/static/hub.js +1511 -0
  87. codex_autorunner/static/index.html +622 -0
  88. codex_autorunner/static/loader.js +28 -0
  89. codex_autorunner/static/logs.js +690 -0
  90. codex_autorunner/static/mobileCompact.js +300 -0
  91. codex_autorunner/static/snapshot.js +116 -0
  92. codex_autorunner/static/state.js +87 -0
  93. codex_autorunner/static/styles.css +4966 -0
  94. codex_autorunner/static/tabs.js +50 -0
  95. codex_autorunner/static/terminal.js +21 -0
  96. codex_autorunner/static/terminalManager.js +3535 -0
  97. codex_autorunner/static/todoPreview.js +25 -0
  98. codex_autorunner/static/types.d.ts +8 -0
  99. codex_autorunner/static/utils.js +597 -0
  100. codex_autorunner/static/vendor/LICENSE.xterm +24 -0
  101. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
  102. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
  103. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
  104. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
  105. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
  106. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
  107. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
  108. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
  109. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
  110. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
  111. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
  112. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
  113. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
  114. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
  115. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
  116. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
  117. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
  118. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
  119. codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
  120. codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
  121. codex_autorunner/static/vendor/xterm.css +209 -0
  122. codex_autorunner/static/vendor/xterm.js +2 -0
  123. codex_autorunner/static/voice.js +591 -0
  124. codex_autorunner/voice/__init__.py +39 -0
  125. codex_autorunner/voice/capture.py +349 -0
  126. codex_autorunner/voice/config.py +167 -0
  127. codex_autorunner/voice/provider.py +66 -0
  128. codex_autorunner/voice/providers/__init__.py +7 -0
  129. codex_autorunner/voice/providers/openai_whisper.py +345 -0
  130. codex_autorunner/voice/resolver.py +36 -0
  131. codex_autorunner/voice/service.py +210 -0
  132. codex_autorunner/web/__init__.py +1 -0
  133. codex_autorunner/web/app.py +1037 -0
  134. codex_autorunner/web/hub_jobs.py +181 -0
  135. codex_autorunner/web/middleware.py +552 -0
  136. codex_autorunner/web/pty_session.py +357 -0
  137. codex_autorunner/web/runner_manager.py +25 -0
  138. codex_autorunner/web/schemas.py +253 -0
  139. codex_autorunner/web/static_assets.py +430 -0
  140. codex_autorunner/web/terminal_sessions.py +78 -0
  141. codex_autorunner/workspace.py +16 -0
  142. codex_autorunner-0.1.0.dist-info/METADATA +240 -0
  143. codex_autorunner-0.1.0.dist-info/RECORD +147 -0
  144. codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
  145. codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
  146. codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
  147. codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ INJECTED_CONTEXT_START = "<injected context>"
4
+ INJECTED_CONTEXT_END = "</injected context>"
5
+
6
+
7
+ def wrap_injected_context(text: str) -> str:
8
+ """Wrap prompt hints in injected context blocks."""
9
+ return f"{INJECTED_CONTEXT_START}\n{text}\n{INJECTED_CONTEXT_END}"
@@ -0,0 +1,57 @@
1
+ import json
2
+ import os
3
+ import socket
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from .utils import atomic_write
9
+
10
+
11
+ @dataclass
12
+ class LockInfo:
13
+ pid: Optional[int]
14
+ started_at: Optional[str]
15
+ host: Optional[str]
16
+
17
+
18
+ def process_alive(pid: int) -> bool:
19
+ try:
20
+ os.kill(pid, 0)
21
+ except OSError:
22
+ return False
23
+ return True
24
+
25
+
26
+ def read_lock_info(lock_path: Path) -> LockInfo:
27
+ if not lock_path.exists():
28
+ return LockInfo(pid=None, started_at=None, host=None)
29
+ try:
30
+ text = lock_path.read_text(encoding="utf-8").strip()
31
+ except OSError:
32
+ return LockInfo(pid=None, started_at=None, host=None)
33
+ if not text:
34
+ return LockInfo(pid=None, started_at=None, host=None)
35
+ if text.startswith("{"):
36
+ try:
37
+ payload = json.loads(text)
38
+ pid = payload.get("pid")
39
+ return LockInfo(
40
+ pid=int(pid) if isinstance(pid, int) or str(pid).isdigit() else None,
41
+ started_at=payload.get("started_at"),
42
+ host=payload.get("host"),
43
+ )
44
+ except Exception:
45
+ return LockInfo(pid=None, started_at=None, host=None)
46
+ pid = int(text) if text.isdigit() else None
47
+ return LockInfo(pid=pid, started_at=None, host=None)
48
+
49
+
50
+ def write_lock_info(lock_path: Path, pid: int, *, started_at: str) -> None:
51
+ payload = {
52
+ "pid": pid,
53
+ "started_at": started_at,
54
+ "host": socket.gethostname(),
55
+ }
56
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
57
+ atomic_write(lock_path, json.dumps(payload) + "\n")
@@ -0,0 +1,158 @@
1
+ import collections
2
+ import json
3
+ import logging
4
+ from logging.handlers import RotatingFileHandler
5
+ from pathlib import Path
6
+ from typing import Any, Mapping, Optional, OrderedDict
7
+
8
+ from .config import LogConfig
9
+ from .request_context import get_request_id
10
+
11
+ _MAX_CACHED_LOGGERS = 64
12
+ _LOGGER_CACHE: "OrderedDict[str, logging.Logger]" = collections.OrderedDict()
13
+ _REDACTED_VALUE = "<redacted>"
14
+ _SENSITIVE_FIELD_PARTS = (
15
+ "api_key",
16
+ "apikey",
17
+ "authorization",
18
+ "bot_token",
19
+ "openai_api_key",
20
+ "password",
21
+ "secret",
22
+ "token",
23
+ )
24
+ _MAX_LOG_STRING = 200
25
+
26
+
27
+ def setup_rotating_logger(name: str, log_config: LogConfig) -> logging.Logger:
28
+ """
29
+ Configure (or retrieve) an isolated rotating logger for the given name.
30
+ Each logger owns a single handler to avoid shared handlers across hub/repos.
31
+ """
32
+ existing = _LOGGER_CACHE.get(name)
33
+ if existing is not None:
34
+ # Keep cache bounded and prefer most-recently-used.
35
+ _LOGGER_CACHE.move_to_end(name)
36
+ return existing
37
+
38
+ log_path: Path = log_config.path
39
+ log_path.parent.mkdir(parents=True, exist_ok=True)
40
+ handler = RotatingFileHandler(
41
+ log_path,
42
+ maxBytes=log_config.max_bytes,
43
+ backupCount=log_config.backup_count,
44
+ encoding="utf-8",
45
+ )
46
+ handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
47
+
48
+ logger = logging.getLogger(name)
49
+ logger.handlers.clear()
50
+ logger.setLevel(logging.INFO)
51
+ logger.addHandler(handler)
52
+ logger.propagate = False
53
+
54
+ _LOGGER_CACHE[name] = logger
55
+ _LOGGER_CACHE.move_to_end(name)
56
+ # Bounded cache to avoid unbounded growth in long-lived hub processes.
57
+ while len(_LOGGER_CACHE) > _MAX_CACHED_LOGGERS:
58
+ _, evicted = _LOGGER_CACHE.popitem(last=False)
59
+ try:
60
+ for h in list(evicted.handlers):
61
+ try:
62
+ h.close()
63
+ except Exception:
64
+ pass
65
+ evicted.handlers.clear()
66
+ except Exception:
67
+ pass
68
+ return logger
69
+
70
+
71
+ def safe_log(
72
+ logger: logging.Logger,
73
+ level: int,
74
+ message: str,
75
+ *args,
76
+ exc: Optional[Exception] = None,
77
+ ) -> None:
78
+ try:
79
+ formatted = message
80
+ if args:
81
+ try:
82
+ formatted = message % args
83
+ except Exception:
84
+ formatted = f"{message} {' '.join(str(arg) for arg in args)}"
85
+ if exc is not None:
86
+ formatted = f"{formatted}: {exc}"
87
+ logger.log(level, formatted)
88
+ except Exception:
89
+ pass
90
+
91
+
92
+ def log_event(
93
+ logger: logging.Logger,
94
+ level: int,
95
+ event: str,
96
+ *,
97
+ exc: Optional[Exception] = None,
98
+ **fields: Any,
99
+ ) -> None:
100
+ payload: dict[str, Any] = {"event": event}
101
+ if "request_id" not in fields:
102
+ request_id = get_request_id()
103
+ if request_id:
104
+ fields["request_id"] = request_id
105
+ if fields:
106
+ payload.update(_sanitize_fields(fields))
107
+ if exc is not None:
108
+ payload["error"] = _sanitize_value(str(exc))
109
+ payload["error_type"] = type(exc).__name__
110
+ try:
111
+ message = json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
112
+ logger.log(level, message)
113
+ except Exception:
114
+ pass
115
+
116
+
117
+ def sanitize_log_value(value: Any) -> Any:
118
+ """Expose the standard log sanitization for ad-hoc values."""
119
+ return _sanitize_value(value)
120
+
121
+
122
+ def _sanitize_fields(fields: Mapping[str, Any]) -> dict[str, Any]:
123
+ sanitized: dict[str, Any] = {}
124
+ for key, value in fields.items():
125
+ if _is_sensitive_key(str(key)):
126
+ sanitized[key] = _REDACTED_VALUE
127
+ else:
128
+ sanitized[key] = _sanitize_value(value)
129
+ return sanitized
130
+
131
+
132
+ def _sanitize_value(value: Any) -> Any:
133
+ if isinstance(value, Mapping):
134
+ return _sanitize_mapping(value)
135
+ if isinstance(value, (list, tuple, set)):
136
+ return [_sanitize_value(item) for item in value]
137
+ if isinstance(value, str):
138
+ if len(value) > _MAX_LOG_STRING:
139
+ return value[: _MAX_LOG_STRING - 3] + "..."
140
+ return value
141
+ if isinstance(value, (int, float, bool)) or value is None:
142
+ return value
143
+ return str(value)
144
+
145
+
146
+ def _sanitize_mapping(mapping: Mapping[str, Any]) -> dict[str, Any]:
147
+ sanitized: dict[str, Any] = {}
148
+ for key, value in mapping.items():
149
+ if _is_sensitive_key(str(key)):
150
+ sanitized[key] = _REDACTED_VALUE
151
+ else:
152
+ sanitized[key] = _sanitize_value(value)
153
+ return sanitized
154
+
155
+
156
+ def _is_sensitive_key(key: str) -> bool:
157
+ lowered = key.lower()
158
+ return any(part in lowered for part in _SENSITIVE_FIELD_PARTS)
@@ -0,0 +1,465 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Optional
7
+
8
+ import httpx
9
+
10
+ from .config import Config
11
+
12
+ DEFAULT_EVENTS = {"run_finished", "run_error", "tui_idle"}
13
+ KNOWN_EVENTS = {"run_finished", "run_error", "tui_idle", "tui_session_finished", "all"}
14
+ DEFAULT_TIMEOUT_SECONDS = 5.0
15
+
16
+
17
+ class NotificationManager:
18
+ def __init__(self, config: Config, *, logger: Optional[logging.Logger] = None):
19
+ self.config = config
20
+ self.logger = logger or logging.getLogger(__name__)
21
+ raw = config.raw.get("notifications")
22
+ self._cfg: Dict[str, Any] = raw if isinstance(raw, dict) else {}
23
+ self._warned_missing: set[str] = set()
24
+ self._enabled_mode = self._parse_enabled(self._cfg.get("enabled"))
25
+ self._events = self._normalize_events(self._cfg.get("events"))
26
+ self._warn_unknown_events(self._events)
27
+ discord_cfg = self._cfg.get("discord")
28
+ self._discord: Dict[str, Any] = (
29
+ discord_cfg if isinstance(discord_cfg, dict) else {}
30
+ )
31
+ telegram_cfg = self._cfg.get("telegram")
32
+ self._telegram: Dict[str, Any] = (
33
+ telegram_cfg if isinstance(telegram_cfg, dict) else {}
34
+ )
35
+ self._discord_enabled = self._discord.get("enabled") is not False
36
+ self._telegram_enabled = self._telegram.get("enabled") is not False
37
+
38
+ def set_logger(self, logger: logging.Logger) -> None:
39
+ self.logger = logger
40
+
41
+ def notify_run_finished(self, *, run_id: int, exit_code: Optional[int]) -> None:
42
+ event = "run_finished" if exit_code == 0 else "run_error"
43
+ message = self._format_run_message(run_id=run_id, exit_code=exit_code)
44
+ self._notify_sync(event, message, repo_path=str(self.config.root))
45
+
46
+ async def notify_run_finished_async(
47
+ self, *, run_id: int, exit_code: Optional[int]
48
+ ) -> None:
49
+ event = "run_finished" if exit_code == 0 else "run_error"
50
+ message = self._format_run_message(run_id=run_id, exit_code=exit_code)
51
+ await self._notify_async(event, message, repo_path=str(self.config.root))
52
+
53
+ def notify_tui_session_finished(
54
+ self,
55
+ *,
56
+ session_id: Optional[str],
57
+ exit_code: Optional[int],
58
+ repo_path: Optional[str] = None,
59
+ ) -> None:
60
+ message = self._format_tui_message(
61
+ session_id=session_id, exit_code=exit_code, repo_path=repo_path
62
+ )
63
+ self._notify_sync("tui_session_finished", message, repo_path=repo_path)
64
+
65
+ async def notify_tui_session_finished_async(
66
+ self,
67
+ *,
68
+ session_id: Optional[str],
69
+ exit_code: Optional[int],
70
+ repo_path: Optional[str] = None,
71
+ ) -> None:
72
+ message = self._format_tui_message(
73
+ session_id=session_id, exit_code=exit_code, repo_path=repo_path
74
+ )
75
+ await self._notify_async("tui_session_finished", message, repo_path=repo_path)
76
+
77
+ def notify_tui_idle(
78
+ self,
79
+ *,
80
+ session_id: Optional[str],
81
+ idle_seconds: float,
82
+ repo_path: Optional[str] = None,
83
+ ) -> None:
84
+ message = self._format_tui_idle_message(
85
+ session_id=session_id,
86
+ idle_seconds=idle_seconds,
87
+ repo_path=repo_path,
88
+ )
89
+ self._notify_sync("tui_idle", message, repo_path=repo_path)
90
+
91
+ async def notify_tui_idle_async(
92
+ self,
93
+ *,
94
+ session_id: Optional[str],
95
+ idle_seconds: float,
96
+ repo_path: Optional[str] = None,
97
+ ) -> None:
98
+ message = self._format_tui_idle_message(
99
+ session_id=session_id,
100
+ idle_seconds=idle_seconds,
101
+ repo_path=repo_path,
102
+ )
103
+ await self._notify_async("tui_idle", message, repo_path=repo_path)
104
+
105
+ def _normalize_events(self, raw_events) -> set[str]:
106
+ if raw_events is None:
107
+ return set(DEFAULT_EVENTS)
108
+ if not isinstance(raw_events, list):
109
+ return set(DEFAULT_EVENTS)
110
+ normalized = {
111
+ item.strip()
112
+ for item in raw_events
113
+ if isinstance(item, str) and item.strip()
114
+ }
115
+ return normalized
116
+
117
+ def _warn_unknown_events(self, events: set[str]) -> None:
118
+ unknown = {event for event in events if event not in KNOWN_EVENTS}
119
+ if not unknown:
120
+ return
121
+ details = ", ".join(sorted(unknown))
122
+ self._warn_once(
123
+ "notifications.unknown_events",
124
+ f"Unknown notification events configured: {details}",
125
+ )
126
+
127
+ def _should_notify(self, event: str) -> bool:
128
+ enabled = self._is_enabled()
129
+ if not enabled:
130
+ return False
131
+ if not self._events:
132
+ return False
133
+ if "all" in self._events:
134
+ return True
135
+ return event in self._events
136
+
137
+ def _parse_enabled(self, raw) -> bool | str:
138
+ if isinstance(raw, bool):
139
+ return raw
140
+ if raw is None:
141
+ return "auto"
142
+ if isinstance(raw, str) and raw.strip().lower() == "auto":
143
+ return "auto"
144
+ return False
145
+
146
+ def _is_enabled(self) -> bool:
147
+ if self._enabled_mode is True:
148
+ return True
149
+ if self._enabled_mode is False:
150
+ return False
151
+ return self._targets_available()
152
+
153
+ def _format_run_message(self, *, run_id: int, exit_code: Optional[int]) -> str:
154
+ repo_label = self._repo_label()
155
+ if exit_code == 0:
156
+ status = "complete"
157
+ summary_text = "summary finalized"
158
+ else:
159
+ status = "failed"
160
+ summary_text = None
161
+ code_text = f"exit {exit_code}" if exit_code is not None else "exit unknown"
162
+ if summary_text:
163
+ details = f"{summary_text}, {code_text}"
164
+ else:
165
+ details = code_text
166
+ return f"CAR run {run_id} {status} ({details}) in {repo_label}"
167
+
168
+ def _format_tui_message(
169
+ self,
170
+ *,
171
+ session_id: Optional[str],
172
+ exit_code: Optional[int],
173
+ repo_path: Optional[str],
174
+ ) -> str:
175
+ repo_label = repo_path or self._repo_label()
176
+ session_text = f"session {session_id}" if session_id else "session"
177
+ code_text = f"exit {exit_code}" if exit_code is not None else "exit unknown"
178
+ return f"CAR TUI session ended ({session_text}, {code_text}) in {repo_label}"
179
+
180
+ def _format_tui_idle_message(
181
+ self,
182
+ *,
183
+ session_id: Optional[str],
184
+ idle_seconds: float,
185
+ repo_path: Optional[str],
186
+ ) -> str:
187
+ repo_label = repo_path or self._repo_label()
188
+ session_text = f"session {session_id}" if session_id else "session"
189
+ idle_text = f"idle {int(idle_seconds)}s"
190
+ return f"CAR TUI idle ({session_text}, {idle_text}) in {repo_label}"
191
+
192
+ def _repo_label(self) -> str:
193
+ name = self.config.root.name
194
+ return name or str(self.config.root)
195
+
196
+ def _notify_sync(
197
+ self, event: str, message: str, *, repo_path: Optional[str] = None
198
+ ) -> None:
199
+ if not self._should_notify(event):
200
+ return
201
+ targets = self._resolve_targets(repo_path=repo_path)
202
+ if not targets:
203
+ return
204
+ try:
205
+ with httpx.Client(timeout=DEFAULT_TIMEOUT_SECONDS) as client:
206
+ self._send_sync(client, targets, message)
207
+ except Exception as exc:
208
+ self._log_warning("Notification delivery failed", exc)
209
+
210
+ async def _notify_async(
211
+ self, event: str, message: str, *, repo_path: Optional[str] = None
212
+ ) -> None:
213
+ if not self._should_notify(event):
214
+ return
215
+ targets = self._resolve_targets(repo_path=repo_path)
216
+ if not targets:
217
+ return
218
+ try:
219
+ async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT_SECONDS) as client:
220
+ await self._send_async(client, targets, message)
221
+ except Exception as exc:
222
+ self._log_warning("Notification delivery failed", exc)
223
+
224
+ def _resolve_targets(
225
+ self, *, repo_path: Optional[str] = None
226
+ ) -> dict[str, dict[str, object]]:
227
+ targets: dict[str, dict[str, object]] = {}
228
+ discord_url = self._resolve_discord_webhook()
229
+ if discord_url:
230
+ targets["discord"] = {"webhook_url": discord_url}
231
+ telegram = self._resolve_telegram(repo_path=repo_path)
232
+ if telegram:
233
+ targets["telegram"] = telegram
234
+ if not targets:
235
+ self._warn_once(
236
+ "notifications.none_configured",
237
+ "Notifications enabled but no targets configured",
238
+ )
239
+ return targets
240
+
241
+ def _targets_available(self) -> bool:
242
+ if self._discord_enabled and self._peek_discord_webhook():
243
+ return True
244
+ if self._telegram_enabled and self._peek_telegram():
245
+ return True
246
+ return False
247
+
248
+ def _peek_discord_webhook(self) -> bool:
249
+ env_key = self._discord.get("webhook_url_env")
250
+ if not env_key or not isinstance(env_key, str):
251
+ return False
252
+ return bool(os.environ.get(env_key))
253
+
254
+ def _peek_telegram(self) -> bool:
255
+ token_key = self._telegram.get("bot_token_env")
256
+ chat_id_key = self._telegram.get("chat_id_env")
257
+ if not token_key or not chat_id_key:
258
+ return False
259
+ if not isinstance(token_key, str) or not isinstance(chat_id_key, str):
260
+ return False
261
+ return bool(os.environ.get(token_key) and os.environ.get(chat_id_key))
262
+
263
+ def _resolve_discord_webhook(self) -> Optional[str]:
264
+ if not self._discord_enabled:
265
+ return None
266
+ env_key = self._discord.get("webhook_url_env")
267
+ if env_key and isinstance(env_key, str):
268
+ value = os.environ.get(env_key)
269
+ if value:
270
+ return value
271
+ if self._discord.get("enabled") is True:
272
+ self._warn_once(
273
+ "discord.webhook_url_env.missing",
274
+ f"Discord webhook env var missing: {env_key}",
275
+ )
276
+ return None
277
+
278
+ def _resolve_telegram(
279
+ self, *, repo_path: Optional[str] = None
280
+ ) -> Optional[dict[str, object]]:
281
+ if not self._telegram_enabled:
282
+ return None
283
+ token_key = self._telegram.get("bot_token_env")
284
+ chat_id_key = self._telegram.get("chat_id_env")
285
+ thread_id_key = self._telegram.get("thread_id_env")
286
+ token = os.environ.get(token_key) if isinstance(token_key, str) else None
287
+ chat_id = os.environ.get(chat_id_key) if isinstance(chat_id_key, str) else None
288
+ thread_id = self._resolve_thread_id(repo_path)
289
+ if thread_id is None:
290
+ thread_id = self._telegram.get("thread_id")
291
+ if not isinstance(thread_id, int):
292
+ thread_id = None
293
+ if thread_id is None:
294
+ thread_id_raw = (
295
+ os.environ.get(thread_id_key)
296
+ if isinstance(thread_id_key, str)
297
+ else None
298
+ )
299
+ if isinstance(thread_id_raw, str) and thread_id_raw.strip():
300
+ try:
301
+ thread_id = int(thread_id_raw.strip())
302
+ except ValueError:
303
+ thread_id = None
304
+ if token and chat_id:
305
+ payload: dict[str, object] = {"bot_token": token, "chat_id": chat_id}
306
+ if thread_id is not None:
307
+ payload["thread_id"] = thread_id
308
+ return payload
309
+ if self._telegram.get("enabled") is True:
310
+ if not token and token_key:
311
+ self._warn_once(
312
+ "telegram.bot_token_env.missing",
313
+ f"Telegram bot token env var missing: {token_key}",
314
+ )
315
+ if not chat_id and chat_id_key:
316
+ self._warn_once(
317
+ "telegram.chat_id_env.missing",
318
+ f"Telegram chat id env var missing: {chat_id_key}",
319
+ )
320
+ return None
321
+
322
+ def _resolve_thread_id(self, repo_path: Optional[str]) -> Optional[int]:
323
+ if not repo_path or not isinstance(self._telegram, dict):
324
+ return None
325
+ thread_map = self._telegram.get("thread_id_map")
326
+ if not isinstance(thread_map, dict):
327
+ return None
328
+ repo_key = self._normalize_repo_path(repo_path)
329
+ if not repo_key:
330
+ return None
331
+ for key, value in thread_map.items():
332
+ if not isinstance(key, str) or not isinstance(value, int):
333
+ continue
334
+ map_key = self._normalize_repo_path(key)
335
+ if map_key and map_key == repo_key:
336
+ return value
337
+ return None
338
+
339
+ def _normalize_repo_path(self, path: str) -> Optional[str]:
340
+ if not isinstance(path, str) or not path.strip():
341
+ return None
342
+ candidate = Path(path).expanduser()
343
+ if not candidate.is_absolute():
344
+ candidate = (self.config.root / candidate).expanduser()
345
+ try:
346
+ return str(candidate.resolve())
347
+ except Exception:
348
+ return str(candidate.absolute())
349
+
350
+ def _send_sync(
351
+ self, client: httpx.Client, targets: dict[str, dict[str, object]], message: str
352
+ ) -> None:
353
+ if "discord" in targets:
354
+ try:
355
+ webhook_url = targets["discord"].get("webhook_url")
356
+ if isinstance(webhook_url, str):
357
+ self._send_discord_sync(client, webhook_url, message)
358
+ except Exception as exc:
359
+ self._log_delivery_failure("discord", exc)
360
+ if "telegram" in targets:
361
+ telegram = targets["telegram"]
362
+ try:
363
+ bot_token = telegram.get("bot_token")
364
+ chat_id = telegram.get("chat_id")
365
+ thread_id = telegram.get("thread_id")
366
+ if isinstance(bot_token, str) and isinstance(chat_id, str):
367
+ self._send_telegram_sync(
368
+ client,
369
+ bot_token,
370
+ chat_id,
371
+ thread_id if isinstance(thread_id, int) else None,
372
+ message,
373
+ )
374
+ except Exception as exc:
375
+ self._log_delivery_failure("telegram", exc)
376
+
377
+ async def _send_async(
378
+ self,
379
+ client: httpx.AsyncClient,
380
+ targets: dict[str, dict[str, object]],
381
+ message: str,
382
+ ) -> None:
383
+ if "discord" in targets:
384
+ try:
385
+ webhook_url = targets["discord"].get("webhook_url")
386
+ if isinstance(webhook_url, str):
387
+ await self._send_discord_async(client, webhook_url, message)
388
+ except Exception as exc:
389
+ self._log_delivery_failure("discord", exc)
390
+ if "telegram" in targets:
391
+ telegram = targets["telegram"]
392
+ try:
393
+ bot_token = telegram.get("bot_token")
394
+ chat_id = telegram.get("chat_id")
395
+ thread_id = telegram.get("thread_id")
396
+ if isinstance(bot_token, str) and isinstance(chat_id, str):
397
+ await self._send_telegram_async(
398
+ client,
399
+ bot_token,
400
+ chat_id,
401
+ thread_id if isinstance(thread_id, int) else None,
402
+ message,
403
+ )
404
+ except Exception as exc:
405
+ self._log_delivery_failure("telegram", exc)
406
+
407
+ def _send_discord_sync(
408
+ self, client: httpx.Client, webhook_url: str, message: str
409
+ ) -> None:
410
+ response = client.post(webhook_url, json={"content": message})
411
+ response.raise_for_status()
412
+
413
+ async def _send_discord_async(
414
+ self, client: httpx.AsyncClient, webhook_url: str, message: str
415
+ ) -> None:
416
+ response = await client.post(webhook_url, json={"content": message})
417
+ response.raise_for_status()
418
+
419
+ def _send_telegram_sync(
420
+ self,
421
+ client: httpx.Client,
422
+ bot_token: str,
423
+ chat_id: str,
424
+ thread_id: Optional[int],
425
+ message: str,
426
+ ) -> None:
427
+ url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
428
+ payload: dict[str, object] = {"chat_id": chat_id, "text": message}
429
+ if thread_id is not None:
430
+ payload["message_thread_id"] = thread_id
431
+ response = client.post(url, json=payload)
432
+ response.raise_for_status()
433
+
434
+ async def _send_telegram_async(
435
+ self,
436
+ client: httpx.AsyncClient,
437
+ bot_token: str,
438
+ chat_id: str,
439
+ thread_id: Optional[int],
440
+ message: str,
441
+ ) -> None:
442
+ url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
443
+ payload: dict[str, object] = {"chat_id": chat_id, "text": message}
444
+ if thread_id is not None:
445
+ payload["message_thread_id"] = thread_id
446
+ response = await client.post(url, json=payload)
447
+ response.raise_for_status()
448
+
449
+ def _warn_once(self, key: str, message: str) -> None:
450
+ if key in self._warned_missing:
451
+ return
452
+ self._warned_missing.add(key)
453
+ self._log_warning(message)
454
+
455
+ def _log_delivery_failure(self, target: str, exc: Exception) -> None:
456
+ self._log_warning(f"Notification delivery failed for {target}", exc)
457
+
458
+ def _log_warning(self, message: str, exc: Optional[Exception] = None) -> None:
459
+ try:
460
+ if exc is not None:
461
+ self.logger.warning("%s: %s", message, exc)
462
+ else:
463
+ self.logger.warning("%s", message)
464
+ except Exception:
465
+ pass