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