codex-autorunner 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. codex_autorunner/__init__.py +3 -0
  2. codex_autorunner/bootstrap.py +151 -0
  3. codex_autorunner/cli.py +886 -0
  4. codex_autorunner/codex_cli.py +79 -0
  5. codex_autorunner/codex_runner.py +17 -0
  6. codex_autorunner/core/__init__.py +1 -0
  7. codex_autorunner/core/about_car.py +125 -0
  8. codex_autorunner/core/codex_runner.py +100 -0
  9. codex_autorunner/core/config.py +1465 -0
  10. codex_autorunner/core/doc_chat.py +547 -0
  11. codex_autorunner/core/docs.py +37 -0
  12. codex_autorunner/core/engine.py +720 -0
  13. codex_autorunner/core/git_utils.py +206 -0
  14. codex_autorunner/core/hub.py +756 -0
  15. codex_autorunner/core/injected_context.py +9 -0
  16. codex_autorunner/core/locks.py +57 -0
  17. codex_autorunner/core/logging_utils.py +158 -0
  18. codex_autorunner/core/notifications.py +465 -0
  19. codex_autorunner/core/optional_dependencies.py +41 -0
  20. codex_autorunner/core/prompt.py +107 -0
  21. codex_autorunner/core/prompts.py +275 -0
  22. codex_autorunner/core/request_context.py +21 -0
  23. codex_autorunner/core/runner_controller.py +116 -0
  24. codex_autorunner/core/runner_process.py +29 -0
  25. codex_autorunner/core/snapshot.py +576 -0
  26. codex_autorunner/core/state.py +156 -0
  27. codex_autorunner/core/update.py +567 -0
  28. codex_autorunner/core/update_runner.py +44 -0
  29. codex_autorunner/core/usage.py +1221 -0
  30. codex_autorunner/core/utils.py +108 -0
  31. codex_autorunner/discovery.py +102 -0
  32. codex_autorunner/housekeeping.py +423 -0
  33. codex_autorunner/integrations/__init__.py +1 -0
  34. codex_autorunner/integrations/app_server/__init__.py +6 -0
  35. codex_autorunner/integrations/app_server/client.py +1386 -0
  36. codex_autorunner/integrations/app_server/supervisor.py +206 -0
  37. codex_autorunner/integrations/github/__init__.py +10 -0
  38. codex_autorunner/integrations/github/service.py +889 -0
  39. codex_autorunner/integrations/telegram/__init__.py +1 -0
  40. codex_autorunner/integrations/telegram/adapter.py +1401 -0
  41. codex_autorunner/integrations/telegram/commands_registry.py +104 -0
  42. codex_autorunner/integrations/telegram/config.py +450 -0
  43. codex_autorunner/integrations/telegram/constants.py +154 -0
  44. codex_autorunner/integrations/telegram/dispatch.py +162 -0
  45. codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
  46. codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
  47. codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
  48. codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
  49. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
  50. codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
  51. codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
  52. codex_autorunner/integrations/telegram/helpers.py +2084 -0
  53. codex_autorunner/integrations/telegram/notifications.py +164 -0
  54. codex_autorunner/integrations/telegram/outbox.py +174 -0
  55. codex_autorunner/integrations/telegram/rendering.py +102 -0
  56. codex_autorunner/integrations/telegram/retry.py +37 -0
  57. codex_autorunner/integrations/telegram/runtime.py +270 -0
  58. codex_autorunner/integrations/telegram/service.py +921 -0
  59. codex_autorunner/integrations/telegram/state.py +1223 -0
  60. codex_autorunner/integrations/telegram/transport.py +318 -0
  61. codex_autorunner/integrations/telegram/types.py +57 -0
  62. codex_autorunner/integrations/telegram/voice.py +413 -0
  63. codex_autorunner/manifest.py +150 -0
  64. codex_autorunner/routes/__init__.py +53 -0
  65. codex_autorunner/routes/base.py +470 -0
  66. codex_autorunner/routes/docs.py +275 -0
  67. codex_autorunner/routes/github.py +197 -0
  68. codex_autorunner/routes/repos.py +121 -0
  69. codex_autorunner/routes/sessions.py +137 -0
  70. codex_autorunner/routes/shared.py +137 -0
  71. codex_autorunner/routes/system.py +175 -0
  72. codex_autorunner/routes/terminal_images.py +107 -0
  73. codex_autorunner/routes/voice.py +128 -0
  74. codex_autorunner/server.py +23 -0
  75. codex_autorunner/spec_ingest.py +113 -0
  76. codex_autorunner/static/app.js +95 -0
  77. codex_autorunner/static/autoRefresh.js +209 -0
  78. codex_autorunner/static/bootstrap.js +105 -0
  79. codex_autorunner/static/bus.js +23 -0
  80. codex_autorunner/static/cache.js +52 -0
  81. codex_autorunner/static/constants.js +48 -0
  82. codex_autorunner/static/dashboard.js +795 -0
  83. codex_autorunner/static/docs.js +1514 -0
  84. codex_autorunner/static/env.js +99 -0
  85. codex_autorunner/static/github.js +168 -0
  86. codex_autorunner/static/hub.js +1511 -0
  87. codex_autorunner/static/index.html +622 -0
  88. codex_autorunner/static/loader.js +28 -0
  89. codex_autorunner/static/logs.js +690 -0
  90. codex_autorunner/static/mobileCompact.js +300 -0
  91. codex_autorunner/static/snapshot.js +116 -0
  92. codex_autorunner/static/state.js +87 -0
  93. codex_autorunner/static/styles.css +4966 -0
  94. codex_autorunner/static/tabs.js +50 -0
  95. codex_autorunner/static/terminal.js +21 -0
  96. codex_autorunner/static/terminalManager.js +3535 -0
  97. codex_autorunner/static/todoPreview.js +25 -0
  98. codex_autorunner/static/types.d.ts +8 -0
  99. codex_autorunner/static/utils.js +597 -0
  100. codex_autorunner/static/vendor/LICENSE.xterm +24 -0
  101. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
  102. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
  103. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
  104. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
  105. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
  106. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
  107. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
  108. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
  109. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
  110. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
  111. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
  112. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
  113. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
  114. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
  115. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
  116. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
  117. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
  118. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
  119. codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
  120. codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
  121. codex_autorunner/static/vendor/xterm.css +209 -0
  122. codex_autorunner/static/vendor/xterm.js +2 -0
  123. codex_autorunner/static/voice.js +591 -0
  124. codex_autorunner/voice/__init__.py +39 -0
  125. codex_autorunner/voice/capture.py +349 -0
  126. codex_autorunner/voice/config.py +167 -0
  127. codex_autorunner/voice/provider.py +66 -0
  128. codex_autorunner/voice/providers/__init__.py +7 -0
  129. codex_autorunner/voice/providers/openai_whisper.py +345 -0
  130. codex_autorunner/voice/resolver.py +36 -0
  131. codex_autorunner/voice/service.py +210 -0
  132. codex_autorunner/web/__init__.py +1 -0
  133. codex_autorunner/web/app.py +1037 -0
  134. codex_autorunner/web/hub_jobs.py +181 -0
  135. codex_autorunner/web/middleware.py +552 -0
  136. codex_autorunner/web/pty_session.py +357 -0
  137. codex_autorunner/web/runner_manager.py +25 -0
  138. codex_autorunner/web/schemas.py +253 -0
  139. codex_autorunner/web/static_assets.py +430 -0
  140. codex_autorunner/web/terminal_sessions.py +78 -0
  141. codex_autorunner/workspace.py +16 -0
  142. codex_autorunner-0.1.0.dist-info/METADATA +240 -0
  143. codex_autorunner-0.1.0.dist-info/RECORD +147 -0
  144. codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
  145. codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
  146. codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
  147. codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,886 @@
1
+ import asyncio
2
+ import ipaddress
3
+ import json
4
+ import logging
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import NoReturn, Optional
9
+
10
+ import httpx
11
+ import typer
12
+ import uvicorn
13
+
14
+ from .bootstrap import seed_hub_files, seed_repo_files
15
+ from .core.config import ConfigError, HubConfig, _normalize_base_path, load_config
16
+ from .core.engine import Engine, LockError, clear_stale_lock, doctor
17
+ from .core.hub import HubSupervisor
18
+ from .core.logging_utils import log_event, setup_rotating_logger
19
+ from .core.optional_dependencies import require_optional_dependencies
20
+ from .core.snapshot import SnapshotError, generate_snapshot
21
+ from .core.state import RunnerState, load_state, now_iso, save_state, state_lock
22
+ from .core.usage import (
23
+ UsageError,
24
+ default_codex_home,
25
+ parse_iso_datetime,
26
+ summarize_hub_usage,
27
+ summarize_repo_usage,
28
+ )
29
+ from .core.utils import RepoNotFoundError, default_editor, find_repo_root
30
+ from .integrations.telegram.adapter import TelegramAPIError, TelegramBotClient
31
+ from .integrations.telegram.service import (
32
+ TelegramBotConfig,
33
+ TelegramBotConfigError,
34
+ TelegramBotLockError,
35
+ TelegramBotService,
36
+ )
37
+ from .manifest import load_manifest
38
+ from .server import create_app, create_hub_app
39
+ from .spec_ingest import (
40
+ SpecIngestError,
41
+ clear_work_docs,
42
+ generate_docs_from_spec,
43
+ write_ingested_docs,
44
+ )
45
+ from .voice import VoiceConfig
46
+
47
+ app = typer.Typer(add_completion=False)
48
+ hub_app = typer.Typer(add_completion=False)
49
+ telegram_app = typer.Typer(add_completion=False)
50
+
51
+
52
+ def _raise_exit(message: str, *, cause: Optional[BaseException] = None) -> NoReturn:
53
+ typer.echo(message, err=True)
54
+ if cause is not None:
55
+ raise typer.Exit(code=1) from cause
56
+ raise typer.Exit(code=1)
57
+
58
+
59
+ def _require_repo_config(repo: Optional[Path]) -> Engine:
60
+ try:
61
+ config = load_config(repo or Path.cwd())
62
+ except ConfigError as exc:
63
+ _raise_exit(str(exc), cause=exc)
64
+ if config.mode != "repo":
65
+ _raise_exit("This command must be run in repo mode (config.mode=repo).")
66
+ return Engine(config.root)
67
+
68
+
69
+ def _require_hub_config(path: Optional[Path]) -> HubConfig:
70
+ try:
71
+ config = load_config(path or Path.cwd())
72
+ except ConfigError as exc:
73
+ _raise_exit(str(exc), cause=exc)
74
+ if not isinstance(config, HubConfig):
75
+ _raise_exit("This command requires hub mode (config.mode=hub).")
76
+ return config
77
+
78
+
79
+ def _build_server_url(config, path: str) -> str:
80
+ base_path = config.server_base_path or ""
81
+ if base_path.endswith("/") and path.startswith("/"):
82
+ base_path = base_path[:-1]
83
+ return f"http://{config.server_host}:{config.server_port}{base_path}{path}"
84
+
85
+
86
+ def _resolve_auth_token(env_name: str) -> Optional[str]:
87
+ if not env_name:
88
+ return None
89
+ value = os.environ.get(env_name)
90
+ if value is None:
91
+ return None
92
+ value = value.strip()
93
+ return value or None
94
+
95
+
96
+ def _require_auth_token(env_name: Optional[str]) -> Optional[str]:
97
+ if not env_name:
98
+ return None
99
+ token = _resolve_auth_token(env_name)
100
+ if not token:
101
+ _raise_exit(
102
+ f"server.auth_token_env is set to {env_name}, but the environment variable is missing."
103
+ )
104
+ return token
105
+
106
+
107
+ def _is_loopback_host(host: str) -> bool:
108
+ if host == "localhost":
109
+ return True
110
+ try:
111
+ return ipaddress.ip_address(host).is_loopback
112
+ except ValueError:
113
+ return False
114
+
115
+
116
+ def _enforce_bind_auth(host: str, token_env: str) -> None:
117
+ if _is_loopback_host(host):
118
+ return
119
+ if _resolve_auth_token(token_env):
120
+ return
121
+ _raise_exit(
122
+ "Refusing to bind to a non-loopback host without server.auth_token_env set."
123
+ )
124
+
125
+
126
+ def _request_json(
127
+ method: str,
128
+ url: str,
129
+ payload: Optional[dict] = None,
130
+ token_env: Optional[str] = None,
131
+ ) -> dict:
132
+ headers = None
133
+ if token_env:
134
+ token = _require_auth_token(token_env)
135
+ headers = {"Authorization": f"Bearer {token}"}
136
+ response = httpx.request(method, url, json=payload, timeout=2.0, headers=headers)
137
+ response.raise_for_status()
138
+ data = response.json()
139
+ return data if isinstance(data, dict) else {}
140
+
141
+
142
+ def _require_optional_feature(
143
+ *, feature: str, deps: list[tuple[str, str]], extra: Optional[str] = None
144
+ ) -> None:
145
+ try:
146
+ require_optional_dependencies(feature=feature, deps=deps, extra=extra)
147
+ except ConfigError as exc:
148
+ _raise_exit(str(exc), cause=exc)
149
+
150
+
151
+ app.add_typer(hub_app, name="hub")
152
+ app.add_typer(telegram_app, name="telegram")
153
+
154
+
155
+ def _has_nested_git(path: Path) -> bool:
156
+ try:
157
+ for child in path.iterdir():
158
+ if not child.is_dir() or child.is_symlink():
159
+ continue
160
+ if (child / ".git").exists():
161
+ return True
162
+ if _has_nested_git(child):
163
+ return True
164
+ except OSError:
165
+ return False
166
+ return False
167
+
168
+
169
+ @app.command()
170
+ def init(
171
+ path: Optional[Path] = typer.Argument(None, help="Repo path; defaults to CWD"),
172
+ force: bool = typer.Option(False, "--force", help="Overwrite existing files"),
173
+ git_init: bool = typer.Option(False, "--git-init", help="Run git init if missing"),
174
+ mode: str = typer.Option(
175
+ "auto",
176
+ "--mode",
177
+ help="Initialization mode: repo, hub, or auto (default)",
178
+ ),
179
+ ):
180
+ """Initialize a repo for Codex autorunner."""
181
+ start_path = (path or Path.cwd()).resolve()
182
+ mode = (mode or "auto").lower()
183
+ if mode not in ("auto", "repo", "hub"):
184
+ _raise_exit("Invalid mode; expected repo, hub, or auto")
185
+
186
+ git_required = True
187
+ target_root: Optional[Path] = None
188
+ selected_mode = mode
189
+
190
+ # First try to treat this as a repo init if requested or auto-detected via .git.
191
+ if mode in ("auto", "repo"):
192
+ try:
193
+ target_root = find_repo_root(start_path)
194
+ selected_mode = "repo"
195
+ except RepoNotFoundError:
196
+ target_root = None
197
+
198
+ # If no git root was found, decide between hub or repo-with-git-init.
199
+ if target_root is None:
200
+ target_root = start_path
201
+ if mode in ("hub",) or (mode == "auto" and _has_nested_git(target_root)):
202
+ selected_mode = "hub"
203
+ git_required = False
204
+ elif git_init:
205
+ selected_mode = "repo"
206
+ subprocess.run(["git", "init"], cwd=target_root, check=False)
207
+ else:
208
+ _raise_exit("No .git directory found; rerun with --git-init to create one")
209
+
210
+ ca_dir = target_root / ".codex-autorunner"
211
+ ca_dir.mkdir(parents=True, exist_ok=True)
212
+
213
+ if selected_mode == "hub":
214
+ seed_hub_files(target_root, force=force)
215
+ typer.echo(f"Initialized hub at {ca_dir}")
216
+ else:
217
+ seed_repo_files(target_root, force=force, git_required=git_required)
218
+ typer.echo(f"Initialized repo at {ca_dir}")
219
+ typer.echo("Init complete")
220
+
221
+
222
+ @app.command()
223
+ def status(repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path")):
224
+ """Show autorunner status."""
225
+ engine = _require_repo_config(repo)
226
+ state = load_state(engine.state_path)
227
+ outstanding, _ = engine.docs.todos()
228
+ session_id = state.repo_to_session.get(str(engine.repo_root))
229
+ session_record = state.sessions.get(session_id) if session_id else None
230
+ typer.echo(f"Repo: {engine.repo_root}")
231
+ typer.echo(f"Status: {state.status}")
232
+ typer.echo(f"Last run id: {state.last_run_id}")
233
+ typer.echo(f"Last exit code: {state.last_exit_code}")
234
+ typer.echo(f"Last start: {state.last_run_started_at}")
235
+ typer.echo(f"Last finish: {state.last_run_finished_at}")
236
+ typer.echo(f"Runner pid: {state.runner_pid}")
237
+ if session_id:
238
+ detail = ""
239
+ if session_record:
240
+ detail = f" (status={session_record.status}, last_seen={session_record.last_seen_at})"
241
+ typer.echo(f"Terminal session: {session_id}{detail}")
242
+ else:
243
+ typer.echo("Terminal session: none")
244
+ typer.echo(f"Outstanding TODO items: {len(outstanding)}")
245
+
246
+
247
+ @app.command()
248
+ def sessions(
249
+ repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
250
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
251
+ ):
252
+ """List active terminal sessions."""
253
+ engine = _require_repo_config(repo)
254
+ config = engine.config
255
+ url = _build_server_url(config, "/api/sessions")
256
+ auth_token = _resolve_auth_token(config.server_auth_token_env)
257
+ if auth_token:
258
+ url = f"{url}?include_abs_paths=1"
259
+ payload = None
260
+ source = "server"
261
+ try:
262
+ payload = _request_json("GET", url, token_env=config.server_auth_token_env)
263
+ except Exception:
264
+ state = load_state(engine.state_path)
265
+ payload = {
266
+ "sessions": [
267
+ {
268
+ "session_id": session_id,
269
+ "repo_path": record.repo_path,
270
+ "created_at": record.created_at,
271
+ "last_seen_at": record.last_seen_at,
272
+ "status": record.status,
273
+ "alive": None,
274
+ }
275
+ for session_id, record in state.sessions.items()
276
+ ],
277
+ "repo_to_session": dict(state.repo_to_session),
278
+ }
279
+ source = "state"
280
+
281
+ if output_json:
282
+ if source != "server":
283
+ payload["source"] = source
284
+ typer.echo(json.dumps(payload, indent=2))
285
+ return
286
+
287
+ sessions_payload = payload.get("sessions", []) if isinstance(payload, dict) else []
288
+ typer.echo(f"Sessions ({source}): {len(sessions_payload)}")
289
+ for entry in sessions_payload:
290
+ if not isinstance(entry, dict):
291
+ continue
292
+ session_id = entry.get("session_id") or "unknown"
293
+ repo_path = entry.get("abs_repo_path") or entry.get("repo_path") or "unknown"
294
+ status = entry.get("status") or "unknown"
295
+ last_seen = entry.get("last_seen_at") or "unknown"
296
+ alive = entry.get("alive")
297
+ alive_text = "unknown" if alive is None else str(bool(alive))
298
+ typer.echo(
299
+ f"- {session_id}: repo={repo_path} status={status} last_seen={last_seen} alive={alive_text}"
300
+ )
301
+
302
+
303
+ @app.command("stop-session")
304
+ def stop_session(
305
+ repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
306
+ session_id: Optional[str] = typer.Option(
307
+ None, "--session", help="Session id to stop"
308
+ ),
309
+ ):
310
+ """Stop a terminal session by id or repo path."""
311
+ engine = _require_repo_config(repo)
312
+ config = engine.config
313
+ payload: dict[str, str] = {}
314
+ if session_id:
315
+ payload["session_id"] = session_id
316
+ else:
317
+ payload["repo_path"] = str(engine.repo_root)
318
+
319
+ url = _build_server_url(config, "/api/sessions/stop")
320
+ try:
321
+ response = _request_json(
322
+ "POST", url, payload, token_env=config.server_auth_token_env
323
+ )
324
+ stopped_id = response.get("session_id", payload.get("session_id", ""))
325
+ typer.echo(f"Stopped session {stopped_id}")
326
+ return
327
+ except Exception:
328
+ pass
329
+
330
+ with state_lock(engine.state_path):
331
+ state = load_state(engine.state_path)
332
+ target_id = payload.get("session_id")
333
+ if not target_id:
334
+ repo_lookup = payload.get("repo_path")
335
+ if repo_lookup:
336
+ target_id = state.repo_to_session.get(repo_lookup)
337
+ if not target_id:
338
+ _raise_exit("Session not found (server unavailable)")
339
+ state.sessions.pop(target_id, None)
340
+ state.repo_to_session = {
341
+ repo_key: sid
342
+ for repo_key, sid in state.repo_to_session.items()
343
+ if sid != target_id
344
+ }
345
+ save_state(engine.state_path, state)
346
+ typer.echo(f"Stopped session {target_id} (state only)")
347
+
348
+
349
+ @app.command()
350
+ def usage(
351
+ repo: Optional[Path] = typer.Option(
352
+ None, "--repo", help="Repo or hub path; defaults to CWD"
353
+ ),
354
+ codex_home: Optional[Path] = typer.Option(
355
+ None, "--codex-home", help="Override CODEX_HOME (defaults to env or ~/.codex)"
356
+ ),
357
+ since: Optional[str] = typer.Option(
358
+ None,
359
+ "--since",
360
+ help="ISO timestamp filter, e.g. 2025-12-01 or 2025-12-01T12:00Z",
361
+ ),
362
+ until: Optional[str] = typer.Option(
363
+ None, "--until", help="Upper bound ISO timestamp filter"
364
+ ),
365
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
366
+ ):
367
+ """Show Codex token usage for a repo or hub by reading CODEX_HOME session logs."""
368
+ try:
369
+ config = load_config(repo or Path.cwd())
370
+ except ConfigError as exc:
371
+ _raise_exit(str(exc), cause=exc)
372
+
373
+ try:
374
+ since_dt = parse_iso_datetime(since)
375
+ until_dt = parse_iso_datetime(until)
376
+ except UsageError as exc:
377
+ _raise_exit(str(exc), cause=exc)
378
+
379
+ codex_root = (codex_home or default_codex_home()).expanduser()
380
+
381
+ if isinstance(config, HubConfig):
382
+ manifest = load_manifest(config.manifest_path, config.root)
383
+ repo_map = [(entry.id, (config.root / entry.path)) for entry in manifest.repos]
384
+ per_repo, unmatched = summarize_hub_usage(
385
+ repo_map,
386
+ codex_root,
387
+ since=since_dt,
388
+ until=until_dt,
389
+ )
390
+ if output_json:
391
+ payload = {
392
+ "mode": "hub",
393
+ "hub_root": str(config.root),
394
+ "codex_home": str(codex_root),
395
+ "since": since,
396
+ "until": until,
397
+ "repos": {
398
+ repo_id: summary.to_dict() for repo_id, summary in per_repo.items()
399
+ },
400
+ "unmatched": unmatched.to_dict(),
401
+ }
402
+ typer.echo(json.dumps(payload, indent=2))
403
+ return
404
+
405
+ typer.echo(f"Hub: {config.root}")
406
+ typer.echo(f"CODEX_HOME: {codex_root}")
407
+ typer.echo(f"Repos: {len(per_repo)}")
408
+ for repo_id, summary in per_repo.items():
409
+ typer.echo(
410
+ f"- {repo_id}: total={summary.totals.total_tokens} "
411
+ f"(input={summary.totals.input_tokens}, cached={summary.totals.cached_input_tokens}, "
412
+ f"output={summary.totals.output_tokens}, reasoning={summary.totals.reasoning_output_tokens}) "
413
+ f"events={summary.events}"
414
+ )
415
+ if unmatched.events or unmatched.totals.total_tokens:
416
+ typer.echo(
417
+ f"- unmatched: total={unmatched.totals.total_tokens} events={unmatched.events}"
418
+ )
419
+ return
420
+
421
+ engine = _require_repo_config(repo)
422
+ summary = summarize_repo_usage(
423
+ engine.repo_root,
424
+ codex_root,
425
+ since=since_dt,
426
+ until=until_dt,
427
+ )
428
+
429
+ if output_json:
430
+ payload = {
431
+ "mode": "repo",
432
+ "repo": str(engine.repo_root),
433
+ "codex_home": str(codex_root),
434
+ "since": since,
435
+ "until": until,
436
+ "usage": summary.to_dict(),
437
+ }
438
+ typer.echo(json.dumps(payload, indent=2))
439
+ return
440
+
441
+ typer.echo(f"Repo: {engine.repo_root}")
442
+ typer.echo(f"CODEX_HOME: {codex_root}")
443
+ typer.echo(
444
+ f"Totals: total={summary.totals.total_tokens} "
445
+ f"(input={summary.totals.input_tokens}, cached={summary.totals.cached_input_tokens}, "
446
+ f"output={summary.totals.output_tokens}, reasoning={summary.totals.reasoning_output_tokens})"
447
+ )
448
+ typer.echo(f"Events counted: {summary.events}")
449
+ if summary.latest_rate_limits:
450
+ primary = summary.latest_rate_limits.get("primary", {}) or {}
451
+ secondary = summary.latest_rate_limits.get("secondary", {}) or {}
452
+ typer.echo(
453
+ f"Latest rate limits: primary_used={primary.get('used_percent')}%/{primary.get('window_minutes')}m, "
454
+ f"secondary_used={secondary.get('used_percent')}%/{secondary.get('window_minutes')}m"
455
+ )
456
+
457
+
458
+ @app.command()
459
+ def run(
460
+ repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
461
+ force: bool = typer.Option(False, "--force", help="Ignore existing lock"),
462
+ ):
463
+ """Run the autorunner loop."""
464
+ engine: Optional[Engine] = None
465
+ try:
466
+ engine = _require_repo_config(repo)
467
+ engine.clear_stop_request()
468
+ engine.acquire_lock(force=force)
469
+ engine.run_loop()
470
+ except (ConfigError, LockError) as exc:
471
+ _raise_exit(str(exc), cause=exc)
472
+ finally:
473
+ if engine:
474
+ try:
475
+ engine.release_lock()
476
+ except Exception:
477
+ pass
478
+
479
+
480
+ @app.command()
481
+ def once(
482
+ repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
483
+ force: bool = typer.Option(False, "--force", help="Ignore existing lock"),
484
+ ):
485
+ """Execute a single Codex run."""
486
+ engine: Optional[Engine] = None
487
+ try:
488
+ engine = _require_repo_config(repo)
489
+ engine.clear_stop_request()
490
+ engine.acquire_lock(force=force)
491
+ engine.run_once()
492
+ except (ConfigError, LockError) as exc:
493
+ _raise_exit(str(exc), cause=exc)
494
+ finally:
495
+ if engine:
496
+ try:
497
+ engine.release_lock()
498
+ except Exception:
499
+ pass
500
+
501
+
502
+ @app.command()
503
+ def kill(repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path")):
504
+ """Force-kill a running autorunner and clear stale lock/state."""
505
+ engine = _require_repo_config(repo)
506
+ pid = engine.kill_running_process()
507
+ with state_lock(engine.state_path):
508
+ state = load_state(engine.state_path)
509
+ new_state = RunnerState(
510
+ last_run_id=state.last_run_id,
511
+ status="error",
512
+ last_exit_code=137,
513
+ last_run_started_at=state.last_run_started_at,
514
+ last_run_finished_at=now_iso(),
515
+ runner_pid=None,
516
+ sessions=state.sessions,
517
+ repo_to_session=state.repo_to_session,
518
+ )
519
+ save_state(engine.state_path, new_state)
520
+ clear_stale_lock(engine.lock_path)
521
+ if pid:
522
+ typer.echo(f"Sent SIGTERM to pid {pid}")
523
+ else:
524
+ typer.echo("No active autorunner process found; cleared stale lock if any.")
525
+
526
+
527
+ @app.command()
528
+ def resume(
529
+ repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
530
+ once: bool = typer.Option(False, "--once", help="Resume with a single run"),
531
+ force: bool = typer.Option(False, "--force", help="Override active lock"),
532
+ ):
533
+ """Resume a stopped/errored autorunner, clearing stale locks if needed."""
534
+ engine: Optional[Engine] = None
535
+ try:
536
+ engine = _require_repo_config(repo)
537
+ engine.clear_stop_request()
538
+ clear_stale_lock(engine.lock_path)
539
+ engine.acquire_lock(force=force)
540
+ engine.run_loop(stop_after_runs=1 if once else None)
541
+ except (ConfigError, LockError) as exc:
542
+ _raise_exit(str(exc), cause=exc)
543
+ finally:
544
+ if engine:
545
+ try:
546
+ engine.release_lock()
547
+ except Exception:
548
+ pass
549
+
550
+
551
+ @app.command()
552
+ def log(
553
+ repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
554
+ run_id: Optional[int] = typer.Option(None, "--run", help="Show a specific run"),
555
+ tail: Optional[int] = typer.Option(None, "--tail", help="Tail last N lines"),
556
+ ):
557
+ """Show autorunner log output."""
558
+ engine = _require_repo_config(repo)
559
+ if not engine.log_path.exists():
560
+ _raise_exit("Log file not found; run init")
561
+
562
+ if run_id is not None:
563
+ block = engine.read_run_block(run_id)
564
+ if not block:
565
+ _raise_exit("run not found")
566
+ typer.echo(block)
567
+ return
568
+
569
+ if tail is not None:
570
+ typer.echo(engine.tail_log(tail))
571
+ else:
572
+ state = load_state(engine.state_path)
573
+ last_id = state.last_run_id
574
+ if last_id is None:
575
+ typer.echo("No runs recorded yet")
576
+ return
577
+ block = engine.read_run_block(last_id)
578
+ if not block:
579
+ typer.echo("No run block found (log may have rotated)")
580
+ return
581
+ typer.echo(block)
582
+
583
+
584
+ @app.command()
585
+ def edit(
586
+ target: str = typer.Argument(..., help="todo|progress|opinions|spec"),
587
+ repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
588
+ ):
589
+ """Open one of the docs in $EDITOR."""
590
+ engine = _require_repo_config(repo)
591
+ config = engine.config
592
+ key = target.lower()
593
+ if key not in ("todo", "progress", "opinions", "spec"):
594
+ _raise_exit("Invalid target; choose todo, progress, opinions, or spec")
595
+ path = config.doc_path(key)
596
+ editor = default_editor()
597
+ typer.echo(f"Opening {path} with {editor}")
598
+ subprocess.run([editor, str(path)])
599
+
600
+
601
+ @app.command("ingest-spec")
602
+ def ingest_spec_cmd(
603
+ repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
604
+ spec: Optional[Path] = typer.Option(
605
+ None, "--spec", help="Path to SPEC (defaults to configured docs.spec)"
606
+ ),
607
+ force: bool = typer.Option(
608
+ False, "--force", help="Overwrite TODO/PROGRESS/OPINIONS"
609
+ ),
610
+ ):
611
+ """Generate TODO/PROGRESS/OPINIONS from SPEC using Codex."""
612
+ try:
613
+ engine = _require_repo_config(repo)
614
+ docs = generate_docs_from_spec(engine, spec_path=spec)
615
+ write_ingested_docs(engine, docs, force=force)
616
+ except (ConfigError, SpecIngestError) as exc:
617
+ _raise_exit(str(exc), cause=exc)
618
+
619
+ typer.echo("Ingested SPEC into TODO/PROGRESS/OPINIONS.")
620
+ for key, content in docs.items():
621
+ lines = len(content.splitlines())
622
+ typer.echo(f"- {key.upper()}: {lines} lines")
623
+
624
+
625
+ @app.command("clear-docs")
626
+ def clear_docs_cmd(
627
+ repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
628
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
629
+ ):
630
+ """Clear TODO/PROGRESS/OPINIONS to empty templates."""
631
+ if not yes:
632
+ confirm = input("Clear TODO/PROGRESS/OPINIONS? Type CLEAR to confirm: ").strip()
633
+ if confirm.upper() != "CLEAR":
634
+ _raise_exit("Aborted.")
635
+ engine = _require_repo_config(repo)
636
+ try:
637
+ clear_work_docs(engine)
638
+ except ConfigError as exc:
639
+ _raise_exit(str(exc), cause=exc)
640
+ typer.echo("Cleared TODO/PROGRESS/OPINIONS.")
641
+
642
+
643
+ @app.command("doctor")
644
+ def doctor_cmd(repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path")):
645
+ """Validate repo setup."""
646
+ engine = _require_repo_config(repo)
647
+ try:
648
+ doctor(engine.repo_root)
649
+ except ConfigError as exc:
650
+ _raise_exit(str(exc), cause=exc)
651
+ typer.echo("Doctor check passed")
652
+
653
+
654
+ @app.command()
655
+ def snapshot(
656
+ repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
657
+ ):
658
+ """Generate or update `.codex-autorunner/SNAPSHOT.md`."""
659
+ engine = _require_repo_config(repo)
660
+ try:
661
+ generate_snapshot(engine)
662
+ except SnapshotError as exc:
663
+ _raise_exit(str(exc), cause=exc)
664
+ typer.echo("Snapshot written to .codex-autorunner/SNAPSHOT.md")
665
+
666
+
667
+ @app.command()
668
+ def serve(
669
+ repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
670
+ host: Optional[str] = typer.Option(None, "--host", help="Host to bind"),
671
+ port: Optional[int] = typer.Option(None, "--port", help="Port to bind"),
672
+ base_path: Optional[str] = typer.Option(
673
+ None, "--base-path", help="Base path for the server"
674
+ ),
675
+ ):
676
+ """Start the web server and UI API."""
677
+ try:
678
+ config = load_config(repo or Path.cwd())
679
+ except ConfigError as exc:
680
+ _raise_exit(str(exc), cause=exc)
681
+ if isinstance(config, HubConfig):
682
+ bind_host = host or config.server_host
683
+ bind_port = port or config.server_port
684
+ normalized_base = (
685
+ _normalize_base_path(base_path)
686
+ if base_path is not None
687
+ else config.server_base_path
688
+ )
689
+ _enforce_bind_auth(bind_host, config.server_auth_token_env)
690
+ typer.echo(
691
+ f"Serving hub on http://{bind_host}:{bind_port}{normalized_base or ''}"
692
+ )
693
+ uvicorn.run(
694
+ create_hub_app(config.root, base_path=normalized_base),
695
+ host=bind_host,
696
+ port=bind_port,
697
+ root_path="",
698
+ access_log=config.server_access_log,
699
+ )
700
+ return
701
+ engine = _require_repo_config(repo)
702
+ normalized_base = (
703
+ _normalize_base_path(base_path)
704
+ if base_path is not None
705
+ else engine.config.server_base_path
706
+ )
707
+ app_instance = create_app(engine.repo_root, base_path=normalized_base)
708
+ bind_host = host or engine.config.server_host
709
+ bind_port = port or engine.config.server_port
710
+ _enforce_bind_auth(bind_host, engine.config.server_auth_token_env)
711
+ typer.echo(f"Serving repo on http://{bind_host}:{bind_port}{normalized_base or ''}")
712
+ uvicorn.run(
713
+ app_instance,
714
+ host=bind_host,
715
+ port=bind_port,
716
+ root_path="",
717
+ access_log=engine.config.server_access_log,
718
+ )
719
+
720
+
721
+ @hub_app.command("create")
722
+ def hub_create(
723
+ repo_id: str = typer.Argument(..., help="Repo id to create and initialize"),
724
+ repo_path: Optional[Path] = typer.Option(
725
+ None,
726
+ "--repo-path",
727
+ help="Custom repo path relative to hub repos_root",
728
+ ),
729
+ path: Optional[Path] = typer.Option(None, "--path", help="Hub root path"),
730
+ force: bool = typer.Option(False, "--force", help="Allow existing directory"),
731
+ git_init: bool = typer.Option(
732
+ True, "--git-init/--no-git-init", help="Run git init in the new repo"
733
+ ),
734
+ ):
735
+ """Create a new git repo under the hub and initialize codex-autorunner files."""
736
+ config = _require_hub_config(path)
737
+ supervisor = HubSupervisor(config)
738
+ try:
739
+ snapshot = supervisor.create_repo(
740
+ repo_id, repo_path, git_init=git_init, force=force
741
+ )
742
+ except Exception as exc:
743
+ _raise_exit(str(exc), cause=exc)
744
+ typer.echo(f"Created repo {snapshot.id} at {snapshot.path}")
745
+
746
+
747
+ @hub_app.command("serve")
748
+ def hub_serve(
749
+ path: Optional[Path] = typer.Option(None, "--path", help="Hub root path"),
750
+ host: Optional[str] = typer.Option(None, "--host", help="Host to bind"),
751
+ port: Optional[int] = typer.Option(None, "--port", help="Port to bind"),
752
+ base_path: Optional[str] = typer.Option(
753
+ None, "--base-path", help="Base path for the server"
754
+ ),
755
+ ):
756
+ """Start the hub supervisor server."""
757
+ config = _require_hub_config(path)
758
+ normalized_base = (
759
+ _normalize_base_path(base_path)
760
+ if base_path is not None
761
+ else config.server_base_path
762
+ )
763
+ bind_host = host or config.server_host
764
+ bind_port = port or config.server_port
765
+ _enforce_bind_auth(bind_host, config.server_auth_token_env)
766
+ typer.echo(f"Serving hub on http://{bind_host}:{bind_port}{normalized_base or ''}")
767
+ uvicorn.run(
768
+ create_hub_app(config.root, base_path=normalized_base),
769
+ host=bind_host,
770
+ port=bind_port,
771
+ root_path="",
772
+ access_log=config.server_access_log,
773
+ )
774
+
775
+
776
+ @hub_app.command("scan")
777
+ def hub_scan(path: Optional[Path] = typer.Option(None, "--path", help="Hub root path")):
778
+ """Trigger discovery/init and print repo statuses."""
779
+ config = _require_hub_config(path)
780
+ supervisor = HubSupervisor(config)
781
+ snapshots = supervisor.scan()
782
+ typer.echo(f"Scanned hub at {config.root} (repos_root={config.repos_root})")
783
+ for snap in snapshots:
784
+ typer.echo(
785
+ f"- {snap.id}: {snap.status.value}, initialized={snap.initialized}, exists={snap.exists_on_disk}"
786
+ )
787
+
788
+
789
+ @telegram_app.command("start")
790
+ def telegram_start(
791
+ path: Optional[Path] = typer.Option(None, "--path", help="Repo or hub root path"),
792
+ ):
793
+ """Start the Telegram bot (polling)."""
794
+ _require_optional_feature(
795
+ feature="telegram",
796
+ deps=[("httpx", "httpx")],
797
+ extra="telegram",
798
+ )
799
+ try:
800
+ config = load_config(path or Path.cwd())
801
+ except ConfigError as exc:
802
+ _raise_exit(str(exc), cause=exc)
803
+ telegram_cfg = TelegramBotConfig.from_raw(
804
+ config.raw.get("telegram_bot") if isinstance(config.raw, dict) else None,
805
+ root=config.root,
806
+ )
807
+ if not telegram_cfg.enabled:
808
+ _raise_exit("telegram_bot is disabled; set telegram_bot.enabled: true")
809
+ try:
810
+ telegram_cfg.validate()
811
+ except TelegramBotConfigError as exc:
812
+ _raise_exit(str(exc), cause=exc)
813
+ logger = setup_rotating_logger("codex-autorunner-telegram", config.log)
814
+ log_event(
815
+ logger,
816
+ logging.INFO,
817
+ "telegram.bot.starting",
818
+ root=str(config.root),
819
+ mode=("hub" if isinstance(config, HubConfig) else "repo"),
820
+ )
821
+ voice_raw = config.raw.get("voice") if isinstance(config.raw, dict) else None
822
+ voice_config = VoiceConfig.from_raw(voice_raw, env=os.environ)
823
+ update_repo_url = config.update_repo_url if isinstance(config, HubConfig) else None
824
+ update_repo_ref = config.update_repo_ref if isinstance(config, HubConfig) else None
825
+
826
+ async def _run() -> None:
827
+ service = TelegramBotService(
828
+ telegram_cfg,
829
+ logger=logger,
830
+ hub_root=config.root if isinstance(config, HubConfig) else None,
831
+ manifest_path=(
832
+ config.manifest_path if isinstance(config, HubConfig) else None
833
+ ),
834
+ voice_config=voice_config,
835
+ housekeeping_config=config.housekeeping,
836
+ update_repo_url=update_repo_url,
837
+ update_repo_ref=update_repo_ref,
838
+ )
839
+ await service.run_polling()
840
+
841
+ try:
842
+ asyncio.run(_run())
843
+ except TelegramBotLockError as exc:
844
+ _raise_exit(str(exc), cause=exc)
845
+
846
+
847
+ @telegram_app.command("health")
848
+ def telegram_health(
849
+ path: Optional[Path] = typer.Option(None, "--path", help="Repo or hub root path"),
850
+ timeout: float = typer.Option(5.0, "--timeout", help="Timeout (seconds)"),
851
+ ):
852
+ """Check Telegram API connectivity for the configured bot."""
853
+ _require_optional_feature(
854
+ feature="telegram",
855
+ deps=[("httpx", "httpx")],
856
+ extra="telegram",
857
+ )
858
+ try:
859
+ config = load_config(path or Path.cwd())
860
+ except ConfigError as exc:
861
+ _raise_exit(str(exc), cause=exc)
862
+ telegram_cfg = TelegramBotConfig.from_raw(
863
+ config.raw.get("telegram_bot") if isinstance(config.raw, dict) else None,
864
+ root=config.root,
865
+ )
866
+ if not telegram_cfg.enabled:
867
+ _raise_exit("telegram_bot is disabled; set telegram_bot.enabled: true")
868
+ bot_token = telegram_cfg.bot_token
869
+ if not bot_token:
870
+ _raise_exit(f"missing bot token env '{telegram_cfg.bot_token_env}'")
871
+ timeout_seconds = max(float(timeout), 0.1)
872
+
873
+ async def _run() -> None:
874
+ async with TelegramBotClient(bot_token) as client:
875
+ await asyncio.wait_for(client.get_me(), timeout=timeout_seconds)
876
+
877
+ try:
878
+ asyncio.run(_run())
879
+ except TelegramAPIError as exc:
880
+ _raise_exit(f"Telegram health check failed: {exc}", cause=exc)
881
+ except Exception as exc:
882
+ _raise_exit(f"Telegram health check failed: {exc}", cause=exc)
883
+
884
+
885
+ if __name__ == "__main__":
886
+ app()