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
@@ -1,580 +0,0 @@
1
- import asyncio
2
- import dataclasses
3
- import hashlib
4
- import json
5
- import os
6
- import re
7
- from datetime import datetime, timezone
8
- from pathlib import Path
9
- from typing import Dict, Iterable, List, Optional, Tuple
10
-
11
- from ..integrations.app_server.client import CodexAppServerError
12
- from ..integrations.app_server.supervisor import WorkspaceAppServerSupervisor
13
- from .app_server_prompts import build_app_server_snapshot_prompt
14
- from .app_server_threads import (
15
- AppServerThreadRegistry,
16
- default_app_server_threads_path,
17
- )
18
- from .config import RepoConfig
19
- from .engine import Engine
20
- from .git_utils import (
21
- git_available,
22
- git_branch,
23
- git_diff_name_status,
24
- git_head_sha,
25
- git_ls_files,
26
- git_status_porcelain,
27
- )
28
- from .utils import atomic_write, read_json
29
-
30
-
31
- class SnapshotError(Exception):
32
- """Raised when snapshot generation fails."""
33
-
34
-
35
- def _repo_config(engine: Engine) -> RepoConfig:
36
- if not isinstance(engine.config, RepoConfig):
37
- raise SnapshotError("Snapshot generation requires a repo workspace config")
38
- return engine.config
39
-
40
-
41
- def _now_iso() -> str:
42
- return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
43
-
44
-
45
- def _sha256_text(text: str) -> str:
46
- return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
47
-
48
-
49
- def _sha256_bytes(blob: bytes) -> str:
50
- return hashlib.sha256(blob).hexdigest()
51
-
52
-
53
- _REDACTIONS: List[Tuple[re.Pattern[str], str]] = [
54
- # OpenAI-like keys.
55
- (re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"), "sk-[REDACTED]"),
56
- # GitHub personal access tokens.
57
- (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"), "gh_[REDACTED]"),
58
- # AWS access key ids (best-effort).
59
- (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "AKIA[REDACTED]"),
60
- # JWT-ish blobs.
61
- (
62
- re.compile(
63
- r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"
64
- ),
65
- "[JWT_REDACTED]",
66
- ),
67
- ]
68
-
69
-
70
- def redact_text(text: str) -> str:
71
- redacted = text
72
- for pattern, replacement in _REDACTIONS:
73
- redacted = pattern.sub(replacement, redacted)
74
- return redacted
75
-
76
-
77
- _DEFAULT_IGNORED_DIRS = {
78
- ".git",
79
- ".hg",
80
- ".svn",
81
- "node_modules",
82
- ".venv",
83
- "venv",
84
- "dist",
85
- "build",
86
- ".mypy_cache",
87
- ".pytest_cache",
88
- ".ruff_cache",
89
- ".cache",
90
- "__pycache__",
91
- ".tox",
92
- }
93
-
94
-
95
- _SECRET_BASENAMES = {
96
- ".env",
97
- ".env.local",
98
- "id_rsa",
99
- "id_ed25519",
100
- "known_hosts",
101
- ".npmrc",
102
- ".pypirc",
103
- }
104
-
105
- _SECRET_EXTS = {".pem", ".key", ".p12", ".pfx", ".kdbx"}
106
-
107
-
108
- def _looks_like_secret_path(path: Path) -> bool:
109
- name = path.name
110
- if name in _SECRET_BASENAMES:
111
- return True
112
- if name.startswith(".env."):
113
- return True
114
- if path.suffix.lower() in _SECRET_EXTS:
115
- return True
116
- return False
117
-
118
-
119
- def _is_probably_binary(blob: bytes) -> bool:
120
- if b"\x00" in blob:
121
- return True
122
- # Heuristic: lots of control chars.
123
- sample = blob[:2048]
124
- if not sample:
125
- return False
126
- control = sum(1 for b in sample if b < 9 or (13 < b < 32))
127
- return (control / len(sample)) > 0.3
128
-
129
-
130
- def _iter_files_fs(repo_root: Path, *, max_files: int = 5000) -> List[str]:
131
- out: List[str] = []
132
- for root, dirs, files in os.walk(repo_root):
133
- rel_root = os.path.relpath(root, repo_root)
134
- if rel_root == ".":
135
- rel_root = ""
136
- dirs[:] = [
137
- d
138
- for d in sorted(dirs)
139
- if d not in _DEFAULT_IGNORED_DIRS
140
- and not (Path(rel_root) / d).parts[:1] == (".git",)
141
- ]
142
- for f in sorted(files):
143
- rel = str(Path(rel_root) / f) if rel_root else f
144
- if _looks_like_secret_path(Path(rel)):
145
- continue
146
- out.append(rel)
147
- if len(out) >= max_files:
148
- return out
149
- return out
150
-
151
-
152
- def _build_tree_outline(
153
- rel_paths: Iterable[str], *, max_depth: int, max_entries: int
154
- ) -> str:
155
- paths = [p for p in rel_paths if p and not _looks_like_secret_path(Path(p))]
156
- paths = sorted(set(paths))
157
-
158
- shown = 0
159
- lines: List[str] = []
160
- last_parts: List[str] = []
161
- for rel in paths:
162
- parts = rel.split("/")
163
- if len(parts) > max_depth:
164
- parts = parts[:max_depth]
165
- parts[-1] = parts[-1] + "/…"
166
-
167
- # Emit minimal directory structure changes based on common prefix.
168
- common = 0
169
- for a, b in zip(last_parts, parts):
170
- if a != b:
171
- break
172
- common += 1
173
-
174
- # Print remaining parts with indentation.
175
- for idx in range(common, len(parts)):
176
- name = parts[idx]
177
- indent = " " * idx
178
- prefix = "- " if idx == 0 else "- "
179
- lines.append(f"{indent}{prefix}{name}")
180
- last_parts = parts
181
-
182
- shown += 1
183
- if shown >= max_entries:
184
- lines.append(f"- … (truncated after {max_entries} entries)")
185
- break
186
- return "\n".join(lines)
187
-
188
-
189
- def _detect_key_files(repo_root: Path) -> List[Path]:
190
- candidates = [
191
- "README.md",
192
- "README.rst",
193
- "pyproject.toml",
194
- "package.json",
195
- "package-lock.json",
196
- "pnpm-lock.yaml",
197
- "yarn.lock",
198
- "requirements.txt",
199
- "setup.py",
200
- "Cargo.toml",
201
- "go.mod",
202
- "Makefile",
203
- "Dockerfile",
204
- ".github/workflows",
205
- "src/codex_autorunner/cli.py",
206
- "src/codex_autorunner/server.py",
207
- "src/codex_autorunner/engine.py",
208
- ]
209
- found: List[Path] = []
210
- for rel in candidates:
211
- p = repo_root / rel
212
- if p.exists():
213
- found.append(p)
214
- return found
215
-
216
-
217
- @dataclasses.dataclass(frozen=True)
218
- class SeedContext:
219
- text: str
220
- bytes_read: int
221
- file_hashes: Dict[str, str]
222
- head_sha: Optional[str]
223
- branch: Optional[str]
224
- seed_hash: str
225
-
226
-
227
- def _read_text_excerpt(
228
- path: Path,
229
- *,
230
- repo_root: Path,
231
- max_bytes: int,
232
- max_chars: int,
233
- budget_bytes: int,
234
- bytes_read_so_far: int,
235
- ) -> Tuple[str, int, Optional[str]]:
236
- rel = str(path.relative_to(repo_root))
237
- if _looks_like_secret_path(Path(rel)):
238
- return "", 0, None
239
- if not path.exists() or not path.is_file():
240
- return "", 0, None
241
-
242
- try:
243
- size = path.stat().st_size
244
- except OSError:
245
- return "", 0, None
246
-
247
- if size > max_bytes:
248
- return f"_Skipped excerpt (>{max_bytes} bytes): `{rel}`_\n", 0, None
249
-
250
- remaining = max(0, budget_bytes - bytes_read_so_far)
251
- if remaining <= 0:
252
- return "", 0, None
253
-
254
- to_read = min(max_bytes, remaining)
255
- try:
256
- blob = path.read_bytes()[:to_read]
257
- except OSError:
258
- return "", 0, None
259
-
260
- if _is_probably_binary(blob):
261
- return f"_Skipped binary file: `{rel}`_\n", 0, None
262
-
263
- decoded = blob.decode("utf-8", errors="replace")
264
- decoded = decoded.replace("\r\n", "\n")
265
- decoded = decoded[:max_chars]
266
- decoded = redact_text(decoded).strip()
267
- if not decoded:
268
- return "", 0, _sha256_bytes(blob)
269
- return decoded + "\n", len(blob), _sha256_bytes(blob)
270
-
271
-
272
- def collect_seed_context(
273
- engine: Engine,
274
- *,
275
- per_file_read_cap_bytes: int = 200_000,
276
- total_read_cap_bytes: int = 1_000_000,
277
- tree_max_depth: int = 4,
278
- tree_max_entries: int = 500,
279
- per_doc_max_chars: int = 2000,
280
- ) -> SeedContext:
281
- repo_root = engine.repo_root
282
- config = _repo_config(engine)
283
- git_ok = git_available(repo_root)
284
- head_sha = git_head_sha(repo_root) if git_ok else None
285
- branch = git_branch(repo_root) if git_ok else None
286
-
287
- files = git_ls_files(repo_root) if git_ok else _iter_files_fs(repo_root)
288
- tree = _build_tree_outline(
289
- files, max_depth=tree_max_depth, max_entries=tree_max_entries
290
- )
291
-
292
- key_files = _detect_key_files(repo_root)
293
-
294
- bytes_read = 0
295
- file_hashes: Dict[str, str] = {}
296
-
297
- def _add_excerpt(title: str, path: Path, max_chars: int) -> str:
298
- nonlocal bytes_read
299
- excerpt, inc, digest = _read_text_excerpt(
300
- path,
301
- repo_root=repo_root,
302
- max_bytes=per_file_read_cap_bytes,
303
- max_chars=max_chars,
304
- budget_bytes=total_read_cap_bytes,
305
- bytes_read_so_far=bytes_read,
306
- )
307
- bytes_read += inc
308
- if digest:
309
- rel = str(path.relative_to(repo_root))
310
- file_hashes[rel] = digest
311
- if not excerpt:
312
- return ""
313
- if excerpt.startswith("_Skipped "):
314
- return f"- {excerpt.strip()}\n"
315
- return f"```text\n{excerpt}```\n"
316
-
317
- # Work docs are always included, but bounded and redacted.
318
- docs = {
319
- "TODO": config.doc_path("todo"),
320
- "PROGRESS": config.doc_path("progress"),
321
- "OPINIONS": config.doc_path("opinions"),
322
- "SPEC": config.doc_path("spec"),
323
- }
324
-
325
- parts: List[str] = []
326
- parts.append("# Seed context (bounded)\n")
327
- parts.append("## Repo identity\n")
328
- parts.append(f"- Root: `{repo_root}`\n")
329
- parts.append(f"- VCS: `git` ({'detected' if git_ok else 'not detected'})\n")
330
- if branch:
331
- parts.append(f"- Branch: `{branch}`\n")
332
- if head_sha:
333
- parts.append(f"- HEAD: `{head_sha}`\n")
334
-
335
- parts.append("\n## Tree outline\n")
336
- parts.append(f"_Max depth={tree_max_depth}, max entries={tree_max_entries}_\n\n")
337
- parts.append(tree + "\n")
338
-
339
- parts.append("\n## Key files\n")
340
- if key_files:
341
- for p in key_files:
342
- rel = str(p.relative_to(repo_root))
343
- parts.append(f"- `{rel}`\n")
344
- else:
345
- parts.append("_No key files detected._\n")
346
-
347
- parts.append("\n## Work docs excerpts\n")
348
- parts.append(
349
- "_Excerpts are capped and redacted; edit the real files in `.codex-autorunner/`._\n\n"
350
- )
351
- for label, path in docs.items():
352
- parts.append(f"### {label} (`{path.relative_to(repo_root)}`)\n")
353
- parts.append(_add_excerpt(label, path, per_doc_max_chars))
354
-
355
- # Optionally include a tiny README excerpt to ground the model.
356
- readme = next((p for p in key_files if p.name.lower().startswith("readme")), None)
357
- if readme:
358
- parts.append("\n## README excerpt\n")
359
- parts.append(_add_excerpt("README", readme, 1200))
360
-
361
- seed_text = "".join(parts).strip() + "\n"
362
- seed_hash = _sha256_text(seed_text)
363
- return SeedContext(
364
- text=seed_text,
365
- bytes_read=bytes_read,
366
- file_hashes=file_hashes,
367
- head_sha=head_sha,
368
- branch=branch,
369
- seed_hash=seed_hash,
370
- )
371
-
372
-
373
- def summarize_changes(
374
- engine: Engine,
375
- *,
376
- previous_state: Optional[dict],
377
- current_seed: SeedContext,
378
- max_lines: int = 60,
379
- ) -> str:
380
- repo_root = engine.repo_root
381
- git_ok = git_available(repo_root)
382
- prev_sha = None
383
- if previous_state:
384
- prev_sha = previous_state.get("head_sha")
385
-
386
- if git_ok and prev_sha:
387
- diff_output = git_diff_name_status(repo_root, prev_sha, "HEAD")
388
- diff_lines = (diff_output or "").strip().splitlines()
389
- diff_lines = [ln for ln in diff_lines if ln.strip()]
390
- if diff_lines:
391
- head = "\n".join(diff_lines[:max_lines])
392
- tail = "\n… (truncated)\n" if len(diff_lines) > max_lines else "\n"
393
- return (
394
- "Changes since last snapshot (git diff --name-status):\n"
395
- f"```text\n{head}{tail}```\n"
396
- )
397
-
398
- status_output = git_status_porcelain(repo_root)
399
- status_lines = (status_output or "").strip().splitlines()
400
- status_lines = [ln for ln in status_lines if ln.strip()]
401
- if status_lines:
402
- head = "\n".join(status_lines[:max_lines])
403
- tail = "\n… (truncated)\n" if len(status_lines) > max_lines else "\n"
404
- return f"Working tree status (git status --porcelain):\n```text\n{head}{tail}```\n"
405
-
406
- # Fallback: compare seed input file hashes we control.
407
- prev_hashes: Dict[str, str] = {}
408
- if previous_state and isinstance(previous_state.get("seed_file_hashes"), dict):
409
- prev_hashes = dict(previous_state["seed_file_hashes"])
410
- changed = []
411
- for rel, digest in sorted(current_seed.file_hashes.items()):
412
- if prev_hashes.get(rel) != digest:
413
- changed.append(rel)
414
- if changed:
415
- shown = changed[:max_lines]
416
- suffix = "\n- … (truncated)" if len(changed) > max_lines else ""
417
- items = "\n".join(f"- `{p}`" for p in shown)
418
- return f"Changes since last snapshot (seed inputs only):\n{items}{suffix}\n"
419
-
420
- if prev_sha and not git_ok:
421
- return "No VCS change summary available (git not detected).\n"
422
- if not prev_sha:
423
- return (
424
- "No previous snapshot SHA recorded; treating as best-effort incremental.\n"
425
- )
426
- return "No changes detected (best-effort).\n"
427
-
428
-
429
- def load_snapshot(engine: Engine) -> Optional[str]:
430
- config = _repo_config(engine)
431
- path = config.doc_path("snapshot")
432
- if not path.exists():
433
- return None
434
- return path.read_text(encoding="utf-8")
435
-
436
-
437
- def load_snapshot_state(engine: Engine) -> Optional[dict]:
438
- config = _repo_config(engine)
439
- return read_json(config.doc_path("snapshot_state"))
440
-
441
-
442
- @dataclasses.dataclass(frozen=True)
443
- class SnapshotResult:
444
- content: str
445
- truncated: bool
446
- state: dict
447
-
448
-
449
- class SnapshotService:
450
- def __init__(
451
- self,
452
- engine: Engine,
453
- *,
454
- app_server_supervisor: Optional[WorkspaceAppServerSupervisor] = None,
455
- app_server_threads: Optional[AppServerThreadRegistry] = None,
456
- ) -> None:
457
- self.engine = engine
458
- self._app_server_supervisor = app_server_supervisor
459
- self._app_server_threads = app_server_threads or AppServerThreadRegistry(
460
- default_app_server_threads_path(self.engine.repo_root)
461
- )
462
- self._lock: Optional[asyncio.Lock] = None
463
-
464
- def _ensure_lock(self) -> asyncio.Lock:
465
- if self._lock is None:
466
- try:
467
- self._lock = asyncio.Lock()
468
- except RuntimeError:
469
- asyncio.set_event_loop(asyncio.new_event_loop())
470
- self._lock = asyncio.Lock()
471
- return self._lock
472
-
473
- def _ensure_app_server(self) -> WorkspaceAppServerSupervisor:
474
- if self._app_server_supervisor is None:
475
- raise SnapshotError("App-server backend is not configured")
476
- return self._app_server_supervisor
477
-
478
- async def generate_snapshot(
479
- self,
480
- ) -> SnapshotResult:
481
- lock = self._ensure_lock()
482
- if lock.locked():
483
- raise SnapshotError("Snapshot generation already running")
484
- async with lock:
485
- config = _repo_config(self.engine)
486
- previous_snapshot = await asyncio.to_thread(load_snapshot, self.engine)
487
- previous_state = await asyncio.to_thread(load_snapshot_state, self.engine)
488
-
489
- seed = await asyncio.to_thread(collect_seed_context, self.engine)
490
- changes = None
491
- if previous_snapshot:
492
- changes = await asyncio.to_thread(
493
- summarize_changes,
494
- self.engine,
495
- previous_state=previous_state,
496
- current_seed=seed,
497
- )
498
-
499
- prompt = build_app_server_snapshot_prompt(
500
- config,
501
- seed_context=seed.text,
502
- previous_snapshot=previous_snapshot,
503
- changes=changes,
504
- )
505
- prompt_hash = _sha256_text(prompt)
506
-
507
- supervisor = self._ensure_app_server()
508
- client = await supervisor.get_client(self.engine.repo_root)
509
- key = "snapshot"
510
- thread_id = self._app_server_threads.get_thread_id(key)
511
- if thread_id:
512
- try:
513
- result = await client.thread_resume(thread_id)
514
- resumed = result.get("id")
515
- if isinstance(resumed, str) and resumed:
516
- thread_id = resumed
517
- self._app_server_threads.set_thread_id(key, thread_id)
518
- except CodexAppServerError:
519
- self._app_server_threads.reset_thread(key)
520
- thread_id = None
521
- if not thread_id:
522
- thread = await client.thread_start(str(self.engine.repo_root))
523
- thread_id = thread.get("id")
524
- if not isinstance(thread_id, str) or not thread_id:
525
- raise SnapshotError("App-server did not return a thread id")
526
- self._app_server_threads.set_thread_id(key, thread_id)
527
-
528
- handle = await client.turn_start(
529
- thread_id,
530
- prompt,
531
- approval_policy="never",
532
- sandbox_policy="dangerFullAccess",
533
- )
534
-
535
- # Wait for completion (no streaming/interrupts for now)
536
- try:
537
- await handle.wait(timeout=300) # 5 mins timeout
538
- except asyncio.TimeoutError as err:
539
- raise SnapshotError("Snapshot generation timed out") from err
540
-
541
- # Read the result from disk
542
- path = config.doc_path("snapshot")
543
- if not path.exists():
544
- raise SnapshotError("Agent failed to write snapshot file")
545
-
546
- final = path.read_text(encoding="utf-8")
547
- final = redact_text(final).strip() + "\n"
548
- truncated = False
549
-
550
- state = {
551
- "generated_at": _now_iso(),
552
- "truncated": truncated,
553
- "head_sha": seed.head_sha,
554
- "branch": seed.branch,
555
- "seed_hash": seed.seed_hash,
556
- "prompt_hash": prompt_hash,
557
- "seed_bytes_read": seed.bytes_read,
558
- "seed_file_hashes": seed.file_hashes,
559
- }
560
-
561
- atomic_write(
562
- config.doc_path("snapshot"),
563
- final if final.endswith("\n") else final + "\n",
564
- )
565
- atomic_write(
566
- config.doc_path("snapshot_state"),
567
- json.dumps(state, indent=2, sort_keys=True) + "\n",
568
- )
569
- return SnapshotResult(content=final, truncated=truncated, state=state)
570
-
571
-
572
- # Keep the original function signature for backward compatibility if needed,
573
- # but it will likely break if used in sync context without loop.
574
- # We will update callers to use SnapshotService.
575
- def generate_snapshot(
576
- engine: Engine,
577
- *,
578
- prefer_large_model: bool = True,
579
- ) -> SnapshotResult:
580
- raise NotImplementedError("Use SnapshotService.generate_snapshot() instead")