codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
codex_autorunner/cli.py CHANGED
@@ -1,1177 +1,8 @@
1
- import asyncio
2
- import ipaddress
3
- import json
4
- import logging
5
- import os
6
- import shlex
7
- import subprocess
8
- import uuid
9
- from pathlib import Path
10
- from typing import NoReturn, Optional
1
+ """Backward-compatible CLI entrypoint.
11
2
 
12
- import httpx
13
- import typer
14
- import uvicorn
15
- import yaml
3
+ Re-export the Typer app from the CLI surface.
4
+ """
16
5
 
17
- from .bootstrap import seed_hub_files, seed_repo_files
18
- from .core.config import (
19
- CONFIG_FILENAME,
20
- ConfigError,
21
- HubConfig,
22
- RepoConfig,
23
- _normalize_base_path,
24
- derive_repo_config,
25
- find_nearest_hub_config_path,
26
- load_hub_config,
27
- )
28
- from .core.engine import DoctorReport, Engine, LockError, clear_stale_lock, doctor
29
- from .core.git_utils import GitError, run_git
30
- from .core.hub import HubSupervisor
31
- from .core.logging_utils import log_event, setup_rotating_logger
32
- from .core.optional_dependencies import require_optional_dependencies
33
- from .core.state import RunnerState, load_state, now_iso, save_state, state_lock
34
- from .core.usage import (
35
- UsageError,
36
- default_codex_home,
37
- parse_iso_datetime,
38
- summarize_hub_usage,
39
- summarize_repo_usage,
40
- )
41
- from .core.utils import RepoNotFoundError, default_editor, find_repo_root
42
- from .integrations.telegram.adapter import TelegramAPIError, TelegramBotClient
43
- from .integrations.telegram.doctor import telegram_doctor_checks
44
- from .integrations.telegram.service import (
45
- TelegramBotConfig,
46
- TelegramBotConfigError,
47
- TelegramBotLockError,
48
- TelegramBotService,
49
- )
50
- from .integrations.telegram.state import TelegramStateStore
51
- from .manifest import load_manifest
52
- from .server import create_hub_app
53
- from .voice import VoiceConfig
6
+ from .surfaces.cli.cli import _resolve_repo_api_path, app, main # noqa: F401
54
7
 
55
- logger = logging.getLogger("codex_autorunner.cli")
56
-
57
- app = typer.Typer(add_completion=False)
58
- hub_app = typer.Typer(add_completion=False)
59
- telegram_app = typer.Typer(add_completion=False)
60
-
61
-
62
- def _raise_exit(message: str, *, cause: Optional[BaseException] = None) -> NoReturn:
63
- typer.echo(message, err=True)
64
- if cause is not None:
65
- raise typer.Exit(code=1) from cause
66
- raise typer.Exit(code=1)
67
-
68
-
69
- def _require_repo_config(repo: Optional[Path], hub: Optional[Path]) -> Engine:
70
- try:
71
- repo_root = find_repo_root(repo or Path.cwd())
72
- except RepoNotFoundError as exc:
73
- _raise_exit("No .git directory found for repo commands.", cause=exc)
74
- try:
75
- return Engine(repo_root, hub_path=hub)
76
- except ConfigError as exc:
77
- _raise_exit(str(exc), cause=exc)
78
-
79
-
80
- def _require_hub_config(path: Optional[Path]) -> HubConfig:
81
- try:
82
- return load_hub_config(path or Path.cwd())
83
- except ConfigError as exc:
84
- _raise_exit(str(exc), cause=exc)
85
-
86
-
87
- def _build_server_url(config, path: str) -> str:
88
- base_path = config.server_base_path or ""
89
- if base_path.endswith("/") and path.startswith("/"):
90
- base_path = base_path[:-1]
91
- return f"http://{config.server_host}:{config.server_port}{base_path}{path}"
92
-
93
-
94
- def _resolve_hub_config_path_for_cli(
95
- repo_root: Path, hub: Optional[Path]
96
- ) -> Optional[Path]:
97
- if hub:
98
- candidate = hub
99
- if candidate.is_dir():
100
- candidate = candidate / CONFIG_FILENAME
101
- return candidate if candidate.exists() else None
102
- return find_nearest_hub_config_path(repo_root)
103
-
104
-
105
- def _resolve_repo_api_path(repo_root: Path, hub: Optional[Path], path: str) -> str:
106
- if not path.startswith("/"):
107
- path = f"/{path}"
108
- hub_config_path = _resolve_hub_config_path_for_cli(repo_root, hub)
109
- if hub_config_path is None:
110
- return path
111
- hub_root = hub_config_path.parent.parent.resolve()
112
- manifest_rel: Optional[str] = None
113
- try:
114
- raw = yaml.safe_load(hub_config_path.read_text(encoding="utf-8")) or {}
115
- if isinstance(raw, dict):
116
- hub_cfg = raw.get("hub")
117
- if isinstance(hub_cfg, dict):
118
- manifest_value = hub_cfg.get("manifest")
119
- if isinstance(manifest_value, str) and manifest_value.strip():
120
- manifest_rel = manifest_value.strip()
121
- except (OSError, yaml.YAMLError, KeyError, ValueError) as exc:
122
- logger.debug("Failed to read hub config for manifest: %s", exc)
123
- manifest_rel = None
124
- manifest_path = hub_root / (manifest_rel or ".codex-autorunner/manifest.yml")
125
- if not manifest_path.exists():
126
- return path
127
- try:
128
- manifest = load_manifest(manifest_path, hub_root)
129
- except (OSError, ValueError, KeyError) as exc:
130
- logger.debug("Failed to load manifest: %s", exc)
131
- return path
132
- repo_root = repo_root.resolve()
133
- for entry in manifest.repos:
134
- candidate = (hub_root / entry.path).resolve()
135
- if candidate == repo_root:
136
- return f"/repos/{entry.id}{path}"
137
- return path
138
-
139
-
140
- def _resolve_auth_token(env_name: str) -> Optional[str]:
141
- if not env_name:
142
- return None
143
- value = os.environ.get(env_name)
144
- if value is None:
145
- return None
146
- value = value.strip()
147
- return value or None
148
-
149
-
150
- def _require_auth_token(env_name: Optional[str]) -> Optional[str]:
151
- if not env_name:
152
- return None
153
- token = _resolve_auth_token(env_name)
154
- if not token:
155
- _raise_exit(
156
- f"server.auth_token_env is set to {env_name}, but the environment variable is missing."
157
- )
158
- return token
159
-
160
-
161
- def _is_loopback_host(host: str) -> bool:
162
- if host == "localhost":
163
- return True
164
- try:
165
- return ipaddress.ip_address(host).is_loopback
166
- except ValueError:
167
- return False
168
-
169
-
170
- def _enforce_bind_auth(host: str, token_env: str) -> None:
171
- if _is_loopback_host(host):
172
- return
173
- if _resolve_auth_token(token_env):
174
- return
175
- _raise_exit(
176
- "Refusing to bind to a non-loopback host without server.auth_token_env set."
177
- )
178
-
179
-
180
- def _request_json(
181
- method: str,
182
- url: str,
183
- payload: Optional[dict] = None,
184
- token_env: Optional[str] = None,
185
- ) -> dict:
186
- headers = None
187
- if token_env:
188
- token = _require_auth_token(token_env)
189
- headers = {"Authorization": f"Bearer {token}"}
190
- response = httpx.request(method, url, json=payload, timeout=2.0, headers=headers)
191
- response.raise_for_status()
192
- data = response.json()
193
- return data if isinstance(data, dict) else {}
194
-
195
-
196
- def _require_optional_feature(
197
- *, feature: str, deps: list[tuple[str, str]], extra: Optional[str] = None
198
- ) -> None:
199
- try:
200
- require_optional_dependencies(feature=feature, deps=deps, extra=extra)
201
- except ConfigError as exc:
202
- _raise_exit(str(exc), cause=exc)
203
-
204
-
205
- app.add_typer(hub_app, name="hub")
206
- app.add_typer(telegram_app, name="telegram")
207
-
208
-
209
- def _has_nested_git(path: Path) -> bool:
210
- try:
211
- for child in path.iterdir():
212
- if not child.is_dir() or child.is_symlink():
213
- continue
214
- if (child / ".git").exists():
215
- return True
216
- if _has_nested_git(child):
217
- return True
218
- except OSError:
219
- return False
220
- return False
221
-
222
-
223
- @app.command()
224
- def init(
225
- path: Optional[Path] = typer.Argument(None, help="Repo path; defaults to CWD"),
226
- force: bool = typer.Option(False, "--force", help="Overwrite existing files"),
227
- git_init: bool = typer.Option(False, "--git-init", help="Run git init if missing"),
228
- mode: str = typer.Option(
229
- "auto",
230
- "--mode",
231
- help="Initialization mode: repo, hub, or auto (default)",
232
- ),
233
- ):
234
- """Initialize a repo for Codex autorunner."""
235
- start_path = (path or Path.cwd()).resolve()
236
- mode = (mode or "auto").lower()
237
- if mode not in ("auto", "repo", "hub"):
238
- _raise_exit("Invalid mode; expected repo, hub, or auto")
239
-
240
- git_required = True
241
- target_root: Optional[Path] = None
242
- selected_mode = mode
243
-
244
- # First try to treat this as a repo init if requested or auto-detected via .git.
245
- if mode in ("auto", "repo"):
246
- try:
247
- target_root = find_repo_root(start_path)
248
- selected_mode = "repo"
249
- except RepoNotFoundError:
250
- target_root = None
251
-
252
- # If no git root was found, decide between hub or repo-with-git-init.
253
- if target_root is None:
254
- target_root = start_path
255
- if mode in ("hub",) or (mode == "auto" and _has_nested_git(target_root)):
256
- selected_mode = "hub"
257
- git_required = False
258
- elif git_init:
259
- selected_mode = "repo"
260
- try:
261
- proc = run_git(["init"], target_root, check=False)
262
- except GitError as exc:
263
- _raise_exit(f"git init failed: {exc}")
264
- if proc.returncode != 0:
265
- detail = (
266
- proc.stderr or proc.stdout or ""
267
- ).strip() or f"exit {proc.returncode}"
268
- _raise_exit(f"git init failed: {detail}")
269
- else:
270
- _raise_exit("No .git directory found; rerun with --git-init to create one")
271
-
272
- ca_dir = target_root / ".codex-autorunner"
273
- ca_dir.mkdir(parents=True, exist_ok=True)
274
-
275
- hub_config_path = find_nearest_hub_config_path(target_root)
276
- try:
277
- if selected_mode == "hub":
278
- seed_hub_files(target_root, force=force)
279
- typer.echo(f"Initialized hub at {ca_dir}")
280
- else:
281
- seed_repo_files(target_root, force=force, git_required=git_required)
282
- typer.echo(f"Initialized repo at {ca_dir}")
283
- if hub_config_path is None:
284
- seed_hub_files(target_root, force=force)
285
- typer.echo(f"Initialized hub at {ca_dir}")
286
- except ConfigError as exc:
287
- _raise_exit(str(exc), cause=exc)
288
- typer.echo("Init complete")
289
-
290
-
291
- @app.command()
292
- def status(
293
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
294
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
295
- output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
296
- ):
297
- """Show autorunner status."""
298
- engine = _require_repo_config(repo, hub)
299
- state = load_state(engine.state_path)
300
- outstanding, _ = engine.docs.todos()
301
- repo_key = str(engine.repo_root)
302
- session_id = state.repo_to_session.get(repo_key) or state.repo_to_session.get(
303
- f"{repo_key}:codex"
304
- )
305
- opencode_session_id = state.repo_to_session.get(f"{repo_key}:opencode")
306
- session_record = state.sessions.get(session_id) if session_id else None
307
- opencode_record = (
308
- state.sessions.get(opencode_session_id) if opencode_session_id else None
309
- )
310
-
311
- if output_json:
312
- hub_config_path = _resolve_hub_config_path_for_cli(engine.repo_root, hub)
313
- payload = {
314
- "repo": str(engine.repo_root),
315
- "hub": (
316
- str(hub_config_path.parent.parent.resolve())
317
- if hub_config_path
318
- else None
319
- ),
320
- "status": state.status,
321
- "last_run_id": state.last_run_id,
322
- "last_exit_code": state.last_exit_code,
323
- "last_run_started_at": state.last_run_started_at,
324
- "last_run_finished_at": state.last_run_finished_at,
325
- "runner_pid": state.runner_pid,
326
- "session_id": session_id,
327
- "session_record": (
328
- {
329
- "repo_path": session_record.repo_path,
330
- "created_at": session_record.created_at,
331
- "last_seen_at": session_record.last_seen_at,
332
- "status": session_record.status,
333
- "agent": session_record.agent,
334
- }
335
- if session_record
336
- else None
337
- ),
338
- "opencode_session_id": opencode_session_id,
339
- "opencode_record": (
340
- {
341
- "repo_path": opencode_record.repo_path,
342
- "created_at": opencode_record.created_at,
343
- "last_seen_at": opencode_record.last_seen_at,
344
- "status": opencode_record.status,
345
- "agent": opencode_record.agent,
346
- }
347
- if opencode_record
348
- else None
349
- ),
350
- "outstanding_todos": len(outstanding),
351
- }
352
- typer.echo(json.dumps(payload, indent=2))
353
- return
354
-
355
- typer.echo(f"Repo: {engine.repo_root}")
356
- typer.echo(f"Status: {state.status}")
357
- typer.echo(f"Last run id: {state.last_run_id}")
358
- typer.echo(f"Last exit code: {state.last_exit_code}")
359
- typer.echo(f"Last start: {state.last_run_started_at}")
360
- typer.echo(f"Last finish: {state.last_run_finished_at}")
361
- typer.echo(f"Runner pid: {state.runner_pid}")
362
- if not session_id and not opencode_session_id:
363
- typer.echo("Terminal session: none")
364
- if session_id:
365
- detail = ""
366
- if session_record:
367
- detail = f" (status={session_record.status}, last_seen={session_record.last_seen_at})"
368
- typer.echo(f"Terminal session (codex): {session_id}{detail}")
369
- if opencode_session_id and opencode_session_id != session_id:
370
- detail = ""
371
- if opencode_record:
372
- detail = f" (status={opencode_record.status}, last_seen={opencode_record.last_seen_at})"
373
- typer.echo(f"Terminal session (opencode): {opencode_session_id}{detail}")
374
- typer.echo(f"Outstanding TODO items: {len(outstanding)}")
375
-
376
-
377
- @app.command()
378
- def sessions(
379
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
380
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
381
- output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
382
- ):
383
- """List active terminal sessions."""
384
- engine = _require_repo_config(repo, hub)
385
- config = engine.config
386
- path = _resolve_repo_api_path(engine.repo_root, hub, "/api/sessions")
387
- url = _build_server_url(config, path)
388
- auth_token = _resolve_auth_token(config.server_auth_token_env)
389
- if auth_token:
390
- url = f"{url}?include_abs_paths=1"
391
- payload = None
392
- source = "server"
393
- try:
394
- payload = _request_json("GET", url, token_env=config.server_auth_token_env)
395
- except (
396
- httpx.HTTPError,
397
- httpx.ConnectError,
398
- httpx.TimeoutException,
399
- OSError,
400
- ) as exc:
401
- logger.debug(
402
- "Failed to fetch sessions from server, falling back to state: %s", exc
403
- )
404
- state = load_state(engine.state_path)
405
- payload = {
406
- "sessions": [
407
- {
408
- "session_id": session_id,
409
- "repo_path": record.repo_path,
410
- "created_at": record.created_at,
411
- "last_seen_at": record.last_seen_at,
412
- "status": record.status,
413
- "alive": None,
414
- }
415
- for session_id, record in state.sessions.items()
416
- ],
417
- "repo_to_session": dict(state.repo_to_session),
418
- }
419
- source = "state"
420
-
421
- if output_json:
422
- if source != "server":
423
- payload["source"] = source
424
- typer.echo(json.dumps(payload, indent=2))
425
- return
426
-
427
- sessions_payload = payload.get("sessions", []) if isinstance(payload, dict) else []
428
- typer.echo(f"Sessions ({source}): {len(sessions_payload)}")
429
- for entry in sessions_payload:
430
- if not isinstance(entry, dict):
431
- continue
432
- session_id = entry.get("session_id") or "unknown"
433
- repo_path = entry.get("abs_repo_path") or entry.get("repo_path") or "unknown"
434
- status = entry.get("status") or "unknown"
435
- last_seen = entry.get("last_seen_at") or "unknown"
436
- alive = entry.get("alive")
437
- alive_text = "unknown" if alive is None else str(bool(alive))
438
- typer.echo(
439
- f"- {session_id}: repo={repo_path} status={status} last_seen={last_seen} alive={alive_text}"
440
- )
441
-
442
-
443
- @app.command("stop-session")
444
- def stop_session(
445
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
446
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
447
- session_id: Optional[str] = typer.Option(
448
- None, "--session", help="Session id to stop"
449
- ),
450
- ):
451
- """Stop a terminal session by id or repo path."""
452
- engine = _require_repo_config(repo, hub)
453
- config = engine.config
454
- payload: dict[str, str] = {}
455
- if session_id:
456
- payload["session_id"] = session_id
457
- else:
458
- payload["repo_path"] = str(engine.repo_root)
459
-
460
- path = _resolve_repo_api_path(engine.repo_root, hub, "/api/sessions/stop")
461
- url = _build_server_url(config, path)
462
- try:
463
- response = _request_json(
464
- "POST", url, payload, token_env=config.server_auth_token_env
465
- )
466
- stopped_id = response.get("session_id", payload.get("session_id", ""))
467
- typer.echo(f"Stopped session {stopped_id}")
468
- return
469
- except (
470
- httpx.HTTPError,
471
- httpx.ConnectError,
472
- httpx.TimeoutException,
473
- OSError,
474
- ) as exc:
475
- logger.debug(
476
- "Failed to stop session via server, falling back to state: %s", exc
477
- )
478
-
479
- with state_lock(engine.state_path):
480
- state = load_state(engine.state_path)
481
- target_id = payload.get("session_id")
482
- if not target_id:
483
- repo_lookup = payload.get("repo_path")
484
- if repo_lookup:
485
- target_id = (
486
- state.repo_to_session.get(repo_lookup)
487
- or state.repo_to_session.get(f"{repo_lookup}:codex")
488
- or state.repo_to_session.get(f"{repo_lookup}:opencode")
489
- )
490
- if not target_id:
491
- _raise_exit("Session not found (server unavailable)")
492
- state.sessions.pop(target_id, None)
493
- state.repo_to_session = {
494
- repo_key: sid
495
- for repo_key, sid in state.repo_to_session.items()
496
- if sid != target_id
497
- }
498
- save_state(engine.state_path, state)
499
- typer.echo(f"Stopped session {target_id} (state only)")
500
-
501
-
502
- @app.command()
503
- def usage(
504
- repo: Optional[Path] = typer.Option(
505
- None, "--repo", help="Repo or hub path; defaults to CWD"
506
- ),
507
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
508
- codex_home: Optional[Path] = typer.Option(
509
- None, "--codex-home", help="Override CODEX_HOME (defaults to env or ~/.codex)"
510
- ),
511
- since: Optional[str] = typer.Option(
512
- None,
513
- "--since",
514
- help="ISO timestamp filter, e.g. 2025-12-01 or 2025-12-01T12:00Z",
515
- ),
516
- until: Optional[str] = typer.Option(
517
- None, "--until", help="Upper bound ISO timestamp filter"
518
- ),
519
- output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
520
- ):
521
- """Show Codex/OpenCode token usage for a repo or hub by reading local session logs."""
522
- try:
523
- since_dt = parse_iso_datetime(since)
524
- until_dt = parse_iso_datetime(until)
525
- except UsageError as exc:
526
- _raise_exit(str(exc), cause=exc)
527
-
528
- codex_root = (codex_home or default_codex_home()).expanduser()
529
-
530
- repo_root: Optional[Path] = None
531
- try:
532
- repo_root = find_repo_root(repo or Path.cwd())
533
- except RepoNotFoundError:
534
- repo_root = None
535
-
536
- if repo_root and (repo_root / ".codex-autorunner" / "state.sqlite3").exists():
537
- engine = _require_repo_config(repo, hub)
538
- else:
539
- try:
540
- config = load_hub_config(hub or repo or Path.cwd())
541
- except ConfigError as exc:
542
- _raise_exit(str(exc), cause=exc)
543
- manifest = load_manifest(config.manifest_path, config.root)
544
- repo_map = [(entry.id, (config.root / entry.path)) for entry in manifest.repos]
545
- per_repo, unmatched = summarize_hub_usage(
546
- repo_map,
547
- codex_root,
548
- since=since_dt,
549
- until=until_dt,
550
- )
551
- if output_json:
552
- payload = {
553
- "mode": "hub",
554
- "hub_root": str(config.root),
555
- "codex_home": str(codex_root),
556
- "since": since,
557
- "until": until,
558
- "repos": {
559
- repo_id: summary.to_dict() for repo_id, summary in per_repo.items()
560
- },
561
- "unmatched": unmatched.to_dict(),
562
- }
563
- typer.echo(json.dumps(payload, indent=2))
564
- return
565
-
566
- typer.echo(f"Hub: {config.root}")
567
- typer.echo(f"CODEX_HOME: {codex_root}")
568
- typer.echo(f"Repos: {len(per_repo)}")
569
- for repo_id, summary in per_repo.items():
570
- typer.echo(
571
- f"- {repo_id}: total={summary.totals.total_tokens} "
572
- f"(input={summary.totals.input_tokens}, cached={summary.totals.cached_input_tokens}, "
573
- f"output={summary.totals.output_tokens}, reasoning={summary.totals.reasoning_output_tokens}) "
574
- f"events={summary.events}"
575
- )
576
- if unmatched.events or unmatched.totals.total_tokens:
577
- typer.echo(
578
- f"- unmatched: total={unmatched.totals.total_tokens} events={unmatched.events}"
579
- )
580
- return
581
-
582
- summary = summarize_repo_usage(
583
- engine.repo_root,
584
- codex_root,
585
- since=since_dt,
586
- until=until_dt,
587
- )
588
-
589
- if output_json:
590
- payload = {
591
- "mode": "repo",
592
- "repo": str(engine.repo_root),
593
- "codex_home": str(codex_root),
594
- "since": since,
595
- "until": until,
596
- "usage": summary.to_dict(),
597
- }
598
- typer.echo(json.dumps(payload, indent=2))
599
- return
600
-
601
- typer.echo(f"Repo: {engine.repo_root}")
602
- typer.echo(f"CODEX_HOME: {codex_root}")
603
- typer.echo(
604
- f"Totals: total={summary.totals.total_tokens} "
605
- f"(input={summary.totals.input_tokens}, cached={summary.totals.cached_input_tokens}, "
606
- f"output={summary.totals.output_tokens}, reasoning={summary.totals.reasoning_output_tokens})"
607
- )
608
- typer.echo(f"Events counted: {summary.events}")
609
- if summary.latest_rate_limits:
610
- primary = summary.latest_rate_limits.get("primary", {}) or {}
611
- secondary = summary.latest_rate_limits.get("secondary", {}) or {}
612
- typer.echo(
613
- f"Latest rate limits: primary_used={primary.get('used_percent')}%/{primary.get('window_minutes')}m, "
614
- f"secondary_used={secondary.get('used_percent')}%/{secondary.get('window_minutes')}m"
615
- )
616
-
617
-
618
- @app.command()
619
- def run(
620
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
621
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
622
- force: bool = typer.Option(False, "--force", help="Ignore existing lock"),
623
- ):
624
- """Run the autorunner loop."""
625
- engine: Optional[Engine] = None
626
- try:
627
- engine = _require_repo_config(repo, hub)
628
- engine.clear_stop_request()
629
- engine.acquire_lock(force=force)
630
- engine.run_loop()
631
- except (ConfigError, LockError) as exc:
632
- _raise_exit(str(exc), cause=exc)
633
- finally:
634
- if engine:
635
- try:
636
- engine.release_lock()
637
- except OSError as exc:
638
- logger.debug("Failed to release lock in run command: %s", exc)
639
-
640
-
641
- @app.command()
642
- def once(
643
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
644
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
645
- force: bool = typer.Option(False, "--force", help="Ignore existing lock"),
646
- ):
647
- """Execute a single Codex run."""
648
- engine: Optional[Engine] = None
649
- try:
650
- engine = _require_repo_config(repo, hub)
651
- engine.clear_stop_request()
652
- engine.acquire_lock(force=force)
653
- engine.run_once()
654
- except (ConfigError, LockError) as exc:
655
- _raise_exit(str(exc), cause=exc)
656
- finally:
657
- if engine:
658
- try:
659
- engine.release_lock()
660
- except OSError as exc:
661
- logger.debug("Failed to release lock in once command: %s", exc)
662
-
663
-
664
- @app.command()
665
- def kill(
666
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
667
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
668
- ):
669
- """Force-kill a running autorunner and clear stale lock/state."""
670
- engine = _require_repo_config(repo, hub)
671
- pid = engine.kill_running_process()
672
- with state_lock(engine.state_path):
673
- state = load_state(engine.state_path)
674
- new_state = RunnerState(
675
- last_run_id=state.last_run_id,
676
- status="error",
677
- last_exit_code=137,
678
- last_run_started_at=state.last_run_started_at,
679
- last_run_finished_at=now_iso(),
680
- autorunner_agent_override=state.autorunner_agent_override,
681
- autorunner_model_override=state.autorunner_model_override,
682
- autorunner_effort_override=state.autorunner_effort_override,
683
- autorunner_approval_policy=state.autorunner_approval_policy,
684
- autorunner_sandbox_mode=state.autorunner_sandbox_mode,
685
- autorunner_workspace_write_network=state.autorunner_workspace_write_network,
686
- runner_pid=None,
687
- sessions=state.sessions,
688
- repo_to_session=state.repo_to_session,
689
- )
690
- save_state(engine.state_path, new_state)
691
- clear_stale_lock(engine.lock_path)
692
- if pid:
693
- typer.echo(f"Sent SIGTERM to pid {pid}")
694
- else:
695
- typer.echo("No active autorunner process found; cleared stale lock if any.")
696
-
697
-
698
- @app.command()
699
- def resume(
700
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
701
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
702
- once: bool = typer.Option(False, "--once", help="Resume with a single run"),
703
- force: bool = typer.Option(False, "--force", help="Override active lock"),
704
- ):
705
- """Resume a stopped/errored autorunner, clearing stale locks if needed."""
706
- engine: Optional[Engine] = None
707
- try:
708
- engine = _require_repo_config(repo, hub)
709
- engine.clear_stop_request()
710
- clear_stale_lock(engine.lock_path)
711
- engine.acquire_lock(force=force)
712
- engine.run_loop(stop_after_runs=1 if once else None)
713
- except (ConfigError, LockError) as exc:
714
- _raise_exit(str(exc), cause=exc)
715
- finally:
716
- if engine:
717
- try:
718
- engine.release_lock()
719
- except OSError as exc:
720
- logger.debug("Failed to release lock in resume command: %s", exc)
721
-
722
-
723
- @app.command()
724
- def log(
725
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
726
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
727
- run_id: Optional[int] = typer.Option(None, "--run", help="Show a specific run"),
728
- tail: Optional[int] = typer.Option(None, "--tail", help="Tail last N lines"),
729
- ):
730
- """Show autorunner log output."""
731
- engine = _require_repo_config(repo, hub)
732
- if not engine.log_path.exists():
733
- _raise_exit("Log file not found; run init")
734
-
735
- if run_id is not None:
736
- block = engine.read_run_block(run_id)
737
- if not block:
738
- _raise_exit("run not found")
739
- typer.echo(block)
740
- return
741
-
742
- if tail is not None:
743
- typer.echo(engine.tail_log(tail))
744
- else:
745
- state = load_state(engine.state_path)
746
- last_id = state.last_run_id
747
- if last_id is None:
748
- typer.echo("No runs recorded yet")
749
- return
750
- block = engine.read_run_block(last_id)
751
- if not block:
752
- typer.echo("No run block found (log may have rotated)")
753
- return
754
- typer.echo(block)
755
-
756
-
757
- @app.command()
758
- def edit(
759
- target: str = typer.Argument(..., help="active_context|decisions|spec"),
760
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
761
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
762
- ):
763
- """Open one of the docs in $EDITOR."""
764
- engine = _require_repo_config(repo, hub)
765
- config = engine.config
766
- key = target.lower()
767
- if key not in ("active_context", "decisions", "spec"):
768
- _raise_exit("Invalid target; choose active_context, decisions, or spec")
769
- path = config.doc_path(key)
770
- editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or default_editor()
771
- editor_parts = shlex.split(editor)
772
- if not editor_parts:
773
- editor_parts = [editor]
774
- typer.echo(f"Opening {path} with {' '.join(editor_parts)}")
775
- subprocess.run([*editor_parts, str(path)])
776
-
777
-
778
- @app.command("doctor")
779
- def doctor_cmd(
780
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo or hub path"),
781
- json_output: bool = typer.Option(False, "--json", help="Output JSON for scripting"),
782
- ):
783
- """Validate repo or hub setup."""
784
- try:
785
- start_path = repo or Path.cwd()
786
- report = doctor(start_path)
787
-
788
- hub_config = load_hub_config(start_path)
789
- repo_config: Optional[RepoConfig] = None
790
- try:
791
- repo_root = find_repo_root(start_path)
792
- repo_config = derive_repo_config(hub_config, repo_root)
793
- except RepoNotFoundError:
794
- repo_config = None
795
-
796
- telegram_checks = telegram_doctor_checks(repo_config or hub_config)
797
- report = DoctorReport(checks=report.checks + telegram_checks)
798
- except ConfigError as exc:
799
- _raise_exit(str(exc), cause=exc)
800
- if json_output:
801
- typer.echo(json.dumps(report.to_dict(), indent=2))
802
- if report.has_errors():
803
- raise typer.Exit(code=1)
804
- return
805
- for check in report.checks:
806
- line = f"- {check.status.upper()}: {check.message}"
807
- if check.fix:
808
- line = f"{line} Fix: {check.fix}"
809
- typer.echo(line)
810
- if report.has_errors():
811
- _raise_exit("Doctor check failed")
812
- typer.echo("Doctor check passed")
813
-
814
-
815
- @app.command()
816
- def serve(
817
- path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
818
- host: Optional[str] = typer.Option(None, "--host", help="Host to bind"),
819
- port: Optional[int] = typer.Option(None, "--port", help="Port to bind"),
820
- base_path: Optional[str] = typer.Option(
821
- None, "--base-path", help="Base path for the server"
822
- ),
823
- ):
824
- """Start the hub web server and UI API."""
825
- try:
826
- config = load_hub_config(path or Path.cwd())
827
- except ConfigError as exc:
828
- _raise_exit(str(exc), cause=exc)
829
- bind_host = host or config.server_host
830
- bind_port = port or config.server_port
831
- normalized_base = (
832
- _normalize_base_path(base_path)
833
- if base_path is not None
834
- else config.server_base_path
835
- )
836
- _enforce_bind_auth(bind_host, config.server_auth_token_env)
837
- typer.echo(f"Serving hub on http://{bind_host}:{bind_port}{normalized_base or ''}")
838
- uvicorn.run(
839
- create_hub_app(config.root, base_path=normalized_base),
840
- host=bind_host,
841
- port=bind_port,
842
- root_path="",
843
- access_log=config.server_access_log,
844
- )
845
-
846
-
847
- @hub_app.command("create")
848
- def hub_create(
849
- repo_id: str = typer.Argument(..., help="Repo id to create and initialize"),
850
- repo_path: Optional[Path] = typer.Option(
851
- None,
852
- "--repo-path",
853
- help="Custom repo path relative to hub repos_root",
854
- ),
855
- path: Optional[Path] = typer.Option(None, "--path", help="Hub root path"),
856
- force: bool = typer.Option(False, "--force", help="Allow existing directory"),
857
- git_init: bool = typer.Option(
858
- True, "--git-init/--no-git-init", help="Run git init in the new repo"
859
- ),
860
- ):
861
- """Create a new git repo under the hub and initialize codex-autorunner files."""
862
- config = _require_hub_config(path)
863
- supervisor = HubSupervisor(config)
864
- try:
865
- snapshot = supervisor.create_repo(
866
- repo_id, repo_path, git_init=git_init, force=force
867
- )
868
- except Exception as exc:
869
- _raise_exit(str(exc), cause=exc)
870
- typer.echo(f"Created repo {snapshot.id} at {snapshot.path}")
871
-
872
-
873
- @hub_app.command("clone")
874
- def hub_clone(
875
- git_url: str = typer.Option(
876
- ..., "--git-url", help="Git URL or local path to clone"
877
- ),
878
- repo_id: Optional[str] = typer.Option(
879
- None, "--id", help="Repo id to register (defaults from git URL)"
880
- ),
881
- repo_path: Optional[Path] = typer.Option(
882
- None,
883
- "--repo-path",
884
- help="Custom repo path relative to hub repos_root",
885
- ),
886
- path: Optional[Path] = typer.Option(None, "--path", help="Hub root path"),
887
- force: bool = typer.Option(False, "--force", help="Allow existing directory"),
888
- ):
889
- """Clone a git repo under the hub and initialize codex-autorunner files."""
890
- config = _require_hub_config(path)
891
- supervisor = HubSupervisor(config)
892
- try:
893
- snapshot = supervisor.clone_repo(
894
- git_url=git_url, repo_id=repo_id, repo_path=repo_path, force=force
895
- )
896
- except Exception as exc:
897
- _raise_exit(str(exc), cause=exc)
898
- typer.echo(
899
- f"Cloned repo {snapshot.id} at {snapshot.path} (status={snapshot.status.value})"
900
- )
901
-
902
-
903
- @hub_app.command("serve")
904
- def hub_serve(
905
- path: Optional[Path] = typer.Option(None, "--path", help="Hub root path"),
906
- host: Optional[str] = typer.Option(None, "--host", help="Host to bind"),
907
- port: Optional[int] = typer.Option(None, "--port", help="Port to bind"),
908
- base_path: Optional[str] = typer.Option(
909
- None, "--base-path", help="Base path for the server"
910
- ),
911
- ):
912
- """Start the hub supervisor server."""
913
- config = _require_hub_config(path)
914
- normalized_base = (
915
- _normalize_base_path(base_path)
916
- if base_path is not None
917
- else config.server_base_path
918
- )
919
- bind_host = host or config.server_host
920
- bind_port = port or config.server_port
921
- _enforce_bind_auth(bind_host, config.server_auth_token_env)
922
- typer.echo(f"Serving hub on http://{bind_host}:{bind_port}{normalized_base or ''}")
923
- uvicorn.run(
924
- create_hub_app(config.root, base_path=normalized_base),
925
- host=bind_host,
926
- port=bind_port,
927
- root_path="",
928
- access_log=config.server_access_log,
929
- )
930
-
931
-
932
- @hub_app.command("scan")
933
- def hub_scan(path: Optional[Path] = typer.Option(None, "--path", help="Hub root path")):
934
- """Trigger discovery/init and print repo statuses."""
935
- config = _require_hub_config(path)
936
- supervisor = HubSupervisor(config)
937
- snapshots = supervisor.scan()
938
- typer.echo(f"Scanned hub at {config.root} (repos_root={config.repos_root})")
939
- for snap in snapshots:
940
- typer.echo(
941
- f"- {snap.id}: {snap.status.value}, initialized={snap.initialized}, exists={snap.exists_on_disk}"
942
- )
943
-
944
-
945
- @telegram_app.command("start")
946
- def telegram_start(
947
- path: Optional[Path] = typer.Option(None, "--path", help="Repo or hub root path"),
948
- ):
949
- """Start the Telegram bot (polling)."""
950
- _require_optional_feature(
951
- feature="telegram",
952
- deps=[("httpx", "httpx")],
953
- extra="telegram",
954
- )
955
- try:
956
- config = load_hub_config(path or Path.cwd())
957
- except ConfigError as exc:
958
- _raise_exit(str(exc), cause=exc)
959
- telegram_cfg = TelegramBotConfig.from_raw(
960
- config.raw.get("telegram_bot") if isinstance(config.raw, dict) else None,
961
- root=config.root,
962
- agent_binaries=getattr(config, "agents", None)
963
- and {name: agent.binary for name, agent in config.agents.items()},
964
- )
965
- if not telegram_cfg.enabled:
966
- _raise_exit("telegram_bot is disabled; set telegram_bot.enabled: true")
967
- try:
968
- telegram_cfg.validate()
969
- except TelegramBotConfigError as exc:
970
- _raise_exit(str(exc), cause=exc)
971
- logger = setup_rotating_logger("codex-autorunner-telegram", config.log)
972
- log_event(
973
- logger,
974
- logging.INFO,
975
- "telegram.bot.starting",
976
- root=str(config.root),
977
- mode="hub",
978
- )
979
- voice_raw = config.repo_defaults.get("voice") if config.repo_defaults else None
980
- voice_config = VoiceConfig.from_raw(voice_raw, env=os.environ)
981
- update_repo_url = config.update_repo_url
982
- update_repo_ref = config.update_repo_ref
983
-
984
- async def _run() -> None:
985
- service = TelegramBotService(
986
- telegram_cfg,
987
- logger=logger,
988
- hub_root=config.root,
989
- manifest_path=config.manifest_path,
990
- voice_config=voice_config,
991
- housekeeping_config=config.housekeeping,
992
- update_repo_url=update_repo_url,
993
- update_repo_ref=update_repo_ref,
994
- update_skip_checks=config.update_skip_checks,
995
- app_server_auto_restart=config.app_server.auto_restart,
996
- )
997
- await service.run_polling()
998
-
999
- try:
1000
- asyncio.run(_run())
1001
- except TelegramBotLockError as exc:
1002
- _raise_exit(str(exc), cause=exc)
1003
-
1004
-
1005
- @telegram_app.command("health")
1006
- def telegram_health(
1007
- path: Optional[Path] = typer.Option(None, "--path", help="Repo or hub root path"),
1008
- timeout: float = typer.Option(5.0, "--timeout", help="Timeout (seconds)"),
1009
- ):
1010
- """Check Telegram API connectivity for the configured bot."""
1011
- _require_optional_feature(
1012
- feature="telegram",
1013
- deps=[("httpx", "httpx")],
1014
- extra="telegram",
1015
- )
1016
- try:
1017
- config = load_hub_config(path or Path.cwd())
1018
- except ConfigError as exc:
1019
- _raise_exit(str(exc), cause=exc)
1020
- telegram_cfg = TelegramBotConfig.from_raw(
1021
- config.raw.get("telegram_bot") if isinstance(config.raw, dict) else None,
1022
- root=config.root,
1023
- agent_binaries=getattr(config, "agents", None)
1024
- and {name: agent.binary for name, agent in config.agents.items()},
1025
- )
1026
- if not telegram_cfg.enabled:
1027
- _raise_exit("telegram_bot is disabled; set telegram_bot.enabled: true")
1028
- bot_token = telegram_cfg.bot_token
1029
- if not bot_token:
1030
- _raise_exit(f"missing bot token env '{telegram_cfg.bot_token_env}'")
1031
- timeout_seconds = max(float(timeout), 0.1)
1032
-
1033
- async def _run() -> None:
1034
- async with TelegramBotClient(bot_token) as client:
1035
- await asyncio.wait_for(client.get_me(), timeout=timeout_seconds)
1036
-
1037
- try:
1038
- asyncio.run(_run())
1039
- except TelegramAPIError as exc:
1040
- _raise_exit(f"Telegram health check failed: {exc}", cause=exc)
1041
-
1042
-
1043
- @telegram_app.command("state-check")
1044
- def telegram_state_check(
1045
- path: Optional[Path] = typer.Option(None, "--path", help="Repo or hub root path"),
1046
- ):
1047
- """Open the Telegram state DB and ensure schema migrations apply."""
1048
- try:
1049
- config = load_hub_config(path or Path.cwd())
1050
- except ConfigError as exc:
1051
- _raise_exit(str(exc), cause=exc)
1052
- telegram_cfg = TelegramBotConfig.from_raw(
1053
- config.raw.get("telegram_bot") if isinstance(config.raw, dict) else None,
1054
- root=config.root,
1055
- agent_binaries=getattr(config, "agents", None)
1056
- and {name: agent.binary for name, agent in config.agents.items()},
1057
- )
1058
- if not telegram_cfg.enabled:
1059
- _raise_exit("telegram_bot is disabled; set telegram_bot.enabled: true")
1060
-
1061
- try:
1062
- store = TelegramStateStore(
1063
- telegram_cfg.state_file,
1064
- default_approval_mode=telegram_cfg.defaults.approval_mode,
1065
- )
1066
- # This will open the DB and apply schema/migrations.
1067
- store._connection_sync() # type: ignore[attr-defined]
1068
- except Exception as exc: # pragma: no cover - defensive runtime check
1069
- _raise_exit(f"Telegram state check failed: {exc}", cause=exc)
1070
-
1071
-
1072
- @app.command()
1073
- def flow(
1074
- action: str = typer.Argument(..., help="worker"),
1075
- repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
1076
- hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
1077
- run_id: Optional[str] = typer.Option(
1078
- None, "--run-id", help="Flow run ID (for worker)"
1079
- ),
1080
- ):
1081
- """Flow runtime commands."""
1082
- engine = _require_repo_config(repo, hub)
1083
-
1084
- if action == "worker":
1085
- if not run_id:
1086
- _raise_exit("--run-id is required for worker command")
1087
- try:
1088
- run_id = str(uuid.UUID(str(run_id)))
1089
- except ValueError:
1090
- _raise_exit("Invalid run_id format; must be a UUID")
1091
-
1092
- from .core.flows import FlowController, FlowStore
1093
- from .core.flows.models import FlowRunStatus
1094
- from .flows.ticket_flow import build_ticket_flow_definition
1095
- from .tickets import AgentPool
1096
-
1097
- db_path = engine.repo_root / ".codex-autorunner" / "flows.db"
1098
- artifacts_root = engine.repo_root / ".codex-autorunner" / "flows"
1099
-
1100
- typer.echo(f"Starting flow worker for run {run_id}")
1101
-
1102
- async def _run_worker():
1103
- typer.echo(f"Flow worker started for {run_id}")
1104
- typer.echo(f"DB path: {db_path}")
1105
- typer.echo(f"Artifacts root: {artifacts_root}")
1106
-
1107
- store = FlowStore(db_path)
1108
- store.initialize()
1109
-
1110
- record = store.get_flow_run(run_id)
1111
- if not record:
1112
- typer.echo(f"Flow run {run_id} not found", err=True)
1113
- store.close()
1114
- raise typer.Exit(code=1)
1115
- store.close()
1116
-
1117
- agent_pool: AgentPool | None = None
1118
-
1119
- def _build_definition(flow_type: str):
1120
- nonlocal agent_pool
1121
- if flow_type == "pr_flow":
1122
- _raise_exit(
1123
- "PR flow is no longer supported. Use ticket_flow instead."
1124
- )
1125
- if flow_type == "ticket_flow":
1126
- agent_pool = AgentPool(engine.config)
1127
- return build_ticket_flow_definition(agent_pool=agent_pool)
1128
- _raise_exit(f"Unknown flow type for run {run_id}: {flow_type}")
1129
- return None
1130
-
1131
- definition = _build_definition(record.flow_type)
1132
- definition.validate()
1133
-
1134
- controller = FlowController(
1135
- definition=definition,
1136
- db_path=db_path,
1137
- artifacts_root=artifacts_root,
1138
- )
1139
- controller.initialize()
1140
-
1141
- record = controller.get_status(run_id)
1142
- if not record:
1143
- typer.echo(f"Flow run {run_id} not found", err=True)
1144
- raise typer.Exit(code=1)
1145
-
1146
- if record.status.is_terminal() and record.status not in {
1147
- FlowRunStatus.STOPPED,
1148
- FlowRunStatus.FAILED,
1149
- }:
1150
- typer.echo(
1151
- f"Flow run {run_id} already completed (status={record.status})"
1152
- )
1153
- return
1154
-
1155
- action = (
1156
- "Resuming" if record.status != FlowRunStatus.PENDING else "Starting"
1157
- )
1158
- typer.echo(f"{action} flow run {run_id} from step: {record.current_step}")
1159
- try:
1160
- final_record = await controller.run_flow(run_id)
1161
- typer.echo(
1162
- f"Flow run {run_id} finished with status {final_record.status}"
1163
- )
1164
- finally:
1165
- if agent_pool is not None:
1166
- try:
1167
- await agent_pool.close()
1168
- except Exception:
1169
- typer.echo("Failed to close agent pool cleanly", err=True)
1170
-
1171
- asyncio.run(_run_worker())
1172
- else:
1173
- _raise_exit(f"Unknown action: {action}")
1174
-
1175
-
1176
- if __name__ == "__main__":
1177
- app()
8
+ __all__ = ["app", "main", "_resolve_repo_api_path"]