codex-autorunner 0.1.1__py3-none-any.whl → 1.0.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 (226) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/__init__.py +20 -0
  3. codex_autorunner/agents/base.py +2 -2
  4. codex_autorunner/agents/codex/harness.py +1 -1
  5. codex_autorunner/agents/opencode/__init__.py +4 -0
  6. codex_autorunner/agents/opencode/agent_config.py +104 -0
  7. codex_autorunner/agents/opencode/client.py +305 -28
  8. codex_autorunner/agents/opencode/harness.py +71 -20
  9. codex_autorunner/agents/opencode/logging.py +225 -0
  10. codex_autorunner/agents/opencode/run_prompt.py +261 -0
  11. codex_autorunner/agents/opencode/runtime.py +1202 -132
  12. codex_autorunner/agents/opencode/supervisor.py +194 -68
  13. codex_autorunner/agents/registry.py +258 -0
  14. codex_autorunner/agents/types.py +2 -2
  15. codex_autorunner/api.py +25 -0
  16. codex_autorunner/bootstrap.py +19 -40
  17. codex_autorunner/cli.py +234 -151
  18. codex_autorunner/core/about_car.py +44 -32
  19. codex_autorunner/core/adapter_utils.py +21 -0
  20. codex_autorunner/core/app_server_events.py +15 -6
  21. codex_autorunner/core/app_server_logging.py +55 -15
  22. codex_autorunner/core/app_server_prompts.py +28 -259
  23. codex_autorunner/core/app_server_threads.py +15 -26
  24. codex_autorunner/core/circuit_breaker.py +183 -0
  25. codex_autorunner/core/codex_runner.py +6 -0
  26. codex_autorunner/core/config.py +555 -133
  27. codex_autorunner/core/docs.py +54 -9
  28. codex_autorunner/core/drafts.py +82 -0
  29. codex_autorunner/core/engine.py +828 -274
  30. codex_autorunner/core/exceptions.py +60 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +178 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +75 -0
  35. codex_autorunner/core/flows/runtime.py +351 -0
  36. codex_autorunner/core/flows/store.py +485 -0
  37. codex_autorunner/core/flows/transition.py +133 -0
  38. codex_autorunner/core/flows/worker_process.py +242 -0
  39. codex_autorunner/core/hub.py +21 -13
  40. codex_autorunner/core/locks.py +118 -1
  41. codex_autorunner/core/logging_utils.py +9 -6
  42. codex_autorunner/core/path_utils.py +123 -0
  43. codex_autorunner/core/prompt.py +15 -7
  44. codex_autorunner/core/redaction.py +29 -0
  45. codex_autorunner/core/retry.py +61 -0
  46. codex_autorunner/core/review.py +888 -0
  47. codex_autorunner/core/review_context.py +161 -0
  48. codex_autorunner/core/run_index.py +223 -0
  49. codex_autorunner/core/runner_controller.py +44 -1
  50. codex_autorunner/core/runner_process.py +30 -1
  51. codex_autorunner/core/sqlite_utils.py +32 -0
  52. codex_autorunner/core/state.py +273 -44
  53. codex_autorunner/core/static_assets.py +55 -0
  54. codex_autorunner/core/supervisor_utils.py +67 -0
  55. codex_autorunner/core/text_delta_coalescer.py +43 -0
  56. codex_autorunner/core/update.py +20 -11
  57. codex_autorunner/core/update_runner.py +2 -0
  58. codex_autorunner/core/usage.py +107 -75
  59. codex_autorunner/core/utils.py +167 -3
  60. codex_autorunner/discovery.py +3 -3
  61. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  62. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  63. codex_autorunner/integrations/agents/__init__.py +27 -0
  64. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  65. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  66. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  67. codex_autorunner/integrations/agents/run_event.py +71 -0
  68. codex_autorunner/integrations/app_server/client.py +708 -153
  69. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  70. codex_autorunner/integrations/telegram/adapter.py +474 -185
  71. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  72. codex_autorunner/integrations/telegram/config.py +239 -1
  73. codex_autorunner/integrations/telegram/constants.py +19 -1
  74. codex_autorunner/integrations/telegram/dispatch.py +44 -8
  75. codex_autorunner/integrations/telegram/doctor.py +47 -0
  76. codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
  77. codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
  78. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
  79. codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
  80. codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
  81. codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
  82. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  83. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
  84. codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
  85. codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
  86. codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
  87. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
  88. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
  89. codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
  90. codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
  91. codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
  92. codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
  93. codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
  94. codex_autorunner/integrations/telegram/helpers.py +90 -18
  95. codex_autorunner/integrations/telegram/notifications.py +126 -35
  96. codex_autorunner/integrations/telegram/outbox.py +214 -43
  97. codex_autorunner/integrations/telegram/progress_stream.py +42 -19
  98. codex_autorunner/integrations/telegram/runtime.py +24 -13
  99. codex_autorunner/integrations/telegram/service.py +500 -129
  100. codex_autorunner/integrations/telegram/state.py +1278 -330
  101. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  102. codex_autorunner/integrations/telegram/transport.py +37 -4
  103. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  104. codex_autorunner/integrations/telegram/types.py +22 -2
  105. codex_autorunner/integrations/telegram/voice.py +14 -15
  106. codex_autorunner/manifest.py +2 -0
  107. codex_autorunner/plugin_api.py +22 -0
  108. codex_autorunner/routes/__init__.py +25 -14
  109. codex_autorunner/routes/agents.py +18 -78
  110. codex_autorunner/routes/analytics.py +239 -0
  111. codex_autorunner/routes/base.py +142 -113
  112. codex_autorunner/routes/file_chat.py +836 -0
  113. codex_autorunner/routes/flows.py +980 -0
  114. codex_autorunner/routes/messages.py +459 -0
  115. codex_autorunner/routes/repos.py +17 -0
  116. codex_autorunner/routes/review.py +148 -0
  117. codex_autorunner/routes/sessions.py +16 -8
  118. codex_autorunner/routes/settings.py +22 -0
  119. codex_autorunner/routes/shared.py +33 -3
  120. codex_autorunner/routes/system.py +22 -1
  121. codex_autorunner/routes/usage.py +87 -0
  122. codex_autorunner/routes/voice.py +5 -13
  123. codex_autorunner/routes/workspace.py +271 -0
  124. codex_autorunner/server.py +2 -1
  125. codex_autorunner/static/agentControls.js +9 -1
  126. codex_autorunner/static/agentEvents.js +248 -0
  127. codex_autorunner/static/app.js +27 -22
  128. codex_autorunner/static/autoRefresh.js +29 -1
  129. codex_autorunner/static/bootstrap.js +1 -0
  130. codex_autorunner/static/bus.js +1 -0
  131. codex_autorunner/static/cache.js +1 -0
  132. codex_autorunner/static/constants.js +20 -4
  133. codex_autorunner/static/dashboard.js +162 -150
  134. codex_autorunner/static/diffRenderer.js +37 -0
  135. codex_autorunner/static/docChatCore.js +324 -0
  136. codex_autorunner/static/docChatStorage.js +65 -0
  137. codex_autorunner/static/docChatVoice.js +65 -0
  138. codex_autorunner/static/docEditor.js +133 -0
  139. codex_autorunner/static/env.js +1 -0
  140. codex_autorunner/static/eventSummarizer.js +166 -0
  141. codex_autorunner/static/fileChat.js +182 -0
  142. codex_autorunner/static/health.js +155 -0
  143. codex_autorunner/static/hub.js +67 -126
  144. codex_autorunner/static/index.html +788 -807
  145. codex_autorunner/static/liveUpdates.js +59 -0
  146. codex_autorunner/static/loader.js +1 -0
  147. codex_autorunner/static/messages.js +470 -0
  148. codex_autorunner/static/mobileCompact.js +2 -1
  149. codex_autorunner/static/settings.js +24 -205
  150. codex_autorunner/static/styles.css +7577 -3758
  151. codex_autorunner/static/tabs.js +28 -5
  152. codex_autorunner/static/terminal.js +14 -0
  153. codex_autorunner/static/terminalManager.js +53 -59
  154. codex_autorunner/static/ticketChatActions.js +333 -0
  155. codex_autorunner/static/ticketChatEvents.js +16 -0
  156. codex_autorunner/static/ticketChatStorage.js +16 -0
  157. codex_autorunner/static/ticketChatStream.js +264 -0
  158. codex_autorunner/static/ticketEditor.js +750 -0
  159. codex_autorunner/static/ticketVoice.js +9 -0
  160. codex_autorunner/static/tickets.js +1315 -0
  161. codex_autorunner/static/utils.js +32 -3
  162. codex_autorunner/static/voice.js +21 -7
  163. codex_autorunner/static/workspace.js +672 -0
  164. codex_autorunner/static/workspaceApi.js +53 -0
  165. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  166. codex_autorunner/tickets/__init__.py +20 -0
  167. codex_autorunner/tickets/agent_pool.py +377 -0
  168. codex_autorunner/tickets/files.py +85 -0
  169. codex_autorunner/tickets/frontmatter.py +55 -0
  170. codex_autorunner/tickets/lint.py +102 -0
  171. codex_autorunner/tickets/models.py +95 -0
  172. codex_autorunner/tickets/outbox.py +232 -0
  173. codex_autorunner/tickets/replies.py +179 -0
  174. codex_autorunner/tickets/runner.py +823 -0
  175. codex_autorunner/tickets/spec_ingest.py +77 -0
  176. codex_autorunner/voice/capture.py +7 -7
  177. codex_autorunner/voice/service.py +51 -9
  178. codex_autorunner/web/app.py +419 -199
  179. codex_autorunner/web/hub_jobs.py +13 -2
  180. codex_autorunner/web/middleware.py +47 -13
  181. codex_autorunner/web/pty_session.py +26 -13
  182. codex_autorunner/web/schemas.py +114 -109
  183. codex_autorunner/web/static_assets.py +55 -42
  184. codex_autorunner/web/static_refresh.py +86 -0
  185. codex_autorunner/workspace/__init__.py +40 -0
  186. codex_autorunner/workspace/paths.py +319 -0
  187. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
  188. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  189. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  190. codex_autorunner/core/doc_chat.py +0 -1415
  191. codex_autorunner/core/snapshot.py +0 -580
  192. codex_autorunner/integrations/github/chatops.py +0 -268
  193. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  194. codex_autorunner/routes/docs.py +0 -381
  195. codex_autorunner/routes/github.py +0 -327
  196. codex_autorunner/routes/runs.py +0 -118
  197. codex_autorunner/spec_ingest.py +0 -788
  198. codex_autorunner/static/docChatActions.js +0 -279
  199. codex_autorunner/static/docChatEvents.js +0 -300
  200. codex_autorunner/static/docChatRender.js +0 -205
  201. codex_autorunner/static/docChatStream.js +0 -361
  202. codex_autorunner/static/docs.js +0 -20
  203. codex_autorunner/static/docsClipboard.js +0 -69
  204. codex_autorunner/static/docsCrud.js +0 -257
  205. codex_autorunner/static/docsDocUpdates.js +0 -62
  206. codex_autorunner/static/docsDrafts.js +0 -16
  207. codex_autorunner/static/docsElements.js +0 -69
  208. codex_autorunner/static/docsInit.js +0 -274
  209. codex_autorunner/static/docsParse.js +0 -160
  210. codex_autorunner/static/docsSnapshot.js +0 -87
  211. codex_autorunner/static/docsSpecIngest.js +0 -263
  212. codex_autorunner/static/docsState.js +0 -127
  213. codex_autorunner/static/docsThreadRegistry.js +0 -44
  214. codex_autorunner/static/docsUi.js +0 -153
  215. codex_autorunner/static/docsVoice.js +0 -56
  216. codex_autorunner/static/github.js +0 -442
  217. codex_autorunner/static/logs.js +0 -640
  218. codex_autorunner/static/runs.js +0 -409
  219. codex_autorunner/static/snapshot.js +0 -124
  220. codex_autorunner/static/state.js +0 -86
  221. codex_autorunner/static/todoPreview.js +0 -27
  222. codex_autorunner/workspace.py +0 -16
  223. codex_autorunner-0.1.1.dist-info/RECORD +0 -191
  224. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  225. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  226. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from ..core.logging_utils import safe_log
8
+ from .static_assets import (
9
+ asset_version,
10
+ materialize_static_assets,
11
+ missing_static_assets,
12
+ require_static_assets,
13
+ )
14
+
15
+
16
+ def _update_static_files(static_files: object, static_dir: Path) -> None:
17
+ try:
18
+ static_files.directory = static_dir
19
+ static_files.all_directories = static_files.get_directories( # type: ignore[attr-defined]
20
+ static_dir,
21
+ static_files.packages, # type: ignore[attr-defined]
22
+ )
23
+ static_files.config_checked = False
24
+ except Exception:
25
+ return
26
+
27
+
28
+ def refresh_static_assets(app: object) -> bool:
29
+ lock = getattr(getattr(app, "state", None), "static_assets_lock", None)
30
+ if lock is None or not lock.acquire(blocking=False):
31
+ return False
32
+ try:
33
+ state = getattr(app, "state", None)
34
+ if state is None:
35
+ return False
36
+ current_dir = getattr(state, "static_dir", None)
37
+ if isinstance(current_dir, Path) and not missing_static_assets(current_dir):
38
+ return True
39
+ config = getattr(state, "config", None)
40
+ logger = getattr(state, "logger", None)
41
+ static_candidates = []
42
+ if config is not None:
43
+ static_candidates.append(config.static_assets)
44
+ hub_static = getattr(state, "hub_static_assets", None)
45
+ if hub_static is not None and (
46
+ not static_candidates
47
+ or hub_static.cache_root != static_candidates[0].cache_root
48
+ ):
49
+ static_candidates.append(hub_static)
50
+ for static_cfg in static_candidates:
51
+ try:
52
+ static_dir, static_context = materialize_static_assets(
53
+ static_cfg.cache_root,
54
+ max_cache_entries=static_cfg.max_cache_entries,
55
+ max_cache_age_days=static_cfg.max_cache_age_days,
56
+ logger=logger,
57
+ )
58
+ require_static_assets(static_dir, logger)
59
+ except Exception as exc:
60
+ if logger is not None:
61
+ safe_log(
62
+ logger,
63
+ logging.WARNING,
64
+ "Static assets refresh failed for cache root %s",
65
+ static_cfg.cache_root,
66
+ exc=exc,
67
+ )
68
+ continue
69
+ old_context: Optional[object] = getattr(
70
+ state, "static_assets_context", None
71
+ )
72
+ if old_context is not None:
73
+ try:
74
+ old_context.close()
75
+ except Exception:
76
+ pass
77
+ state.static_dir = static_dir
78
+ state.static_assets_context = static_context
79
+ state.asset_version = asset_version(static_dir)
80
+ static_files = getattr(state, "static_files", None)
81
+ if static_files is not None:
82
+ _update_static_files(static_files, static_dir)
83
+ return True
84
+ return False
85
+ finally:
86
+ lock.release()
@@ -0,0 +1,40 @@
1
+ """Workspace docs helpers (active context, decisions, spec).
2
+
3
+ Workspace docs are optional and live under `.codex-autorunner/workspace/`.
4
+ They are distinct from tickets, which live under `.codex-autorunner/tickets/`.
5
+ """
6
+
7
+ import hashlib
8
+ from pathlib import Path
9
+
10
+ from ..core.utils import canonicalize_path
11
+ from .paths import (
12
+ WORKSPACE_DOC_KINDS,
13
+ WorkspaceDocKind,
14
+ read_workspace_doc,
15
+ workspace_doc_path,
16
+ write_workspace_doc,
17
+ )
18
+
19
+ WORKSPACE_ID_HEX_LEN = 12
20
+
21
+
22
+ def canonical_workspace_root(path: Path) -> Path:
23
+ return canonicalize_path(path)
24
+
25
+
26
+ def workspace_id_for_path(path: Path) -> str:
27
+ canonical = canonical_workspace_root(path)
28
+ digest = hashlib.sha256(str(canonical).encode("utf-8")).hexdigest()
29
+ return digest[:WORKSPACE_ID_HEX_LEN]
30
+
31
+
32
+ __all__ = [
33
+ "WORKSPACE_DOC_KINDS",
34
+ "WorkspaceDocKind",
35
+ "workspace_doc_path",
36
+ "read_workspace_doc",
37
+ "write_workspace_doc",
38
+ "canonical_workspace_root",
39
+ "workspace_id_for_path",
40
+ ]
@@ -0,0 +1,319 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path, PurePosixPath
7
+ from typing import Literal, cast
8
+
9
+ from ..core import drafts as draft_utils
10
+
11
+ WorkspaceDocKind = Literal["active_context", "decisions", "spec"]
12
+ WORKSPACE_DOC_KINDS: tuple[WorkspaceDocKind, ...] = (
13
+ "active_context",
14
+ "decisions",
15
+ "spec",
16
+ )
17
+
18
+
19
+ @dataclass
20
+ class WorkspaceFile:
21
+ name: str
22
+ path: str # path relative to the workspace directory (POSIX)
23
+ is_pinned: bool = False
24
+ modified_at: str | None = None
25
+
26
+
27
+ def _normalize_kind(kind: str) -> WorkspaceDocKind:
28
+ key = (kind or "").strip().lower()
29
+ if key not in WORKSPACE_DOC_KINDS:
30
+ raise ValueError("invalid workspace doc kind")
31
+ return cast(WorkspaceDocKind, key)
32
+
33
+
34
+ def workspace_dir(repo_root: Path) -> Path:
35
+ return repo_root / ".codex-autorunner" / "workspace"
36
+
37
+
38
+ PINNED_DOC_FILENAMES = {f"{kind}.md" for kind in WORKSPACE_DOC_KINDS}
39
+
40
+
41
+ @dataclass
42
+ class WorkspaceNode:
43
+ name: str
44
+ path: str # relative to workspace dir
45
+ type: Literal["file", "folder"]
46
+ is_pinned: bool = False
47
+ modified_at: str | None = None
48
+ size: int | None = None # files only
49
+ children: list["WorkspaceNode"] | None = None # folders only
50
+
51
+
52
+ def normalize_workspace_rel_path(repo_root: Path, rel_path: str) -> tuple[Path, str]:
53
+ """Normalize a user-supplied workspace path and ensure it stays in-tree.
54
+
55
+ We accept POSIX-style relative paths only, then resolve the full path and
56
+ verify the result is still under the workspace directory. This guards
57
+ against ".." traversal and symlink escapes that CodeQL flagged.
58
+ """
59
+
60
+ base = workspace_dir(repo_root)
61
+ base_real = os.path.realpath(base)
62
+ cleaned = (rel_path or "").strip()
63
+ if not cleaned:
64
+ raise ValueError("invalid workspace file path")
65
+
66
+ relative = PurePosixPath(cleaned)
67
+ if relative.is_absolute() or ".." in relative.parts:
68
+ raise ValueError("invalid workspace file path")
69
+
70
+ # Normalize the relative path to collapse any sneaky segments
71
+ norm_relative = os.path.normpath(relative.as_posix())
72
+ if norm_relative in {".", ""}:
73
+ normalized = ""
74
+ else:
75
+ normalized = norm_relative
76
+
77
+ # Reject traversal or absolute inputs after normalization
78
+ if (
79
+ normalized.startswith("..")
80
+ or normalized.startswith("/")
81
+ or normalized.startswith("\\")
82
+ ):
83
+ raise ValueError("invalid workspace file path")
84
+
85
+ candidate_str = os.path.realpath(os.path.join(base_real, normalized))
86
+ # Ensure the resolved path stays under the workspace directory
87
+ if not (candidate_str == base_real or candidate_str.startswith(base_real + os.sep)):
88
+ raise ValueError("invalid workspace file path")
89
+
90
+ candidate = Path(candidate_str)
91
+ rel_posix = candidate.relative_to(base_real).as_posix()
92
+ return candidate, rel_posix
93
+
94
+
95
+ def sanitize_workspace_filename(filename: str) -> str:
96
+ """Return a safe filename for workspace uploads.
97
+
98
+ We strip any directory components, collapse whitespace, and guard against
99
+ empty names. Caller is responsible for applying any per-workspace policy
100
+ (e.g., overwrite vs. reject).
101
+ """
102
+
103
+ cleaned = (filename or "").strip()
104
+ # Drop any path fragments that may be embedded in the upload
105
+ base = PurePosixPath(cleaned).name
106
+ # Remove remaining separators/backslashes that PurePosixPath.name could keep
107
+ base = base.replace("/", "").replace("\\", "")
108
+ if base in {".", ".."}:
109
+ base = ""
110
+ # Collapse whitespace to single spaces to keep names readable
111
+ base = " ".join(base.split())
112
+ if not base:
113
+ return "upload"
114
+ return base
115
+
116
+
117
+ def workspace_doc_path(repo_root: Path, kind: str) -> Path:
118
+ key = _normalize_kind(kind)
119
+ return workspace_dir(repo_root) / f"{key}.md"
120
+
121
+
122
+ def read_workspace_file(
123
+ repo_root: Path, rel_path: str
124
+ ) -> str: # codeql[py/path-injection]
125
+ path, _ = normalize_workspace_rel_path(repo_root, rel_path)
126
+ if (
127
+ path.is_dir()
128
+ ): # codeql[py/path-injection] validated by normalize_workspace_rel_path
129
+ raise ValueError("path points to a directory")
130
+ if (
131
+ not path.exists()
132
+ ): # codeql[py/path-injection] validated by normalize_workspace_rel_path
133
+ return ""
134
+ return path.read_text(
135
+ encoding="utf-8"
136
+ ) # codeql[py/path-injection] validated by normalize_workspace_rel_path
137
+
138
+
139
+ def write_workspace_file( # codeql[py/path-injection]
140
+ repo_root: Path, rel_path: str, content: str
141
+ ) -> str:
142
+ path, rel_posix = normalize_workspace_rel_path(repo_root, rel_path)
143
+ if (
144
+ path.exists() and path.is_dir()
145
+ ): # codeql[py/path-injection] validated by normalize_workspace_rel_path
146
+ raise ValueError("path points to a directory")
147
+ path.parent.mkdir(
148
+ parents=True, exist_ok=True
149
+ ) # codeql[py/path-injection] validated by normalize_workspace_rel_path
150
+ path.write_text(
151
+ content or "", encoding="utf-8"
152
+ ) # codeql[py/path-injection] validated by normalize_workspace_rel_path
153
+ try:
154
+ rel = path.relative_to(repo_root).as_posix()
155
+ draft_utils.invalidate_drafts_for_path(repo_root, rel)
156
+ state_key = f"workspace_{rel_posix.replace('/', '_')}"
157
+ draft_utils.remove_draft(repo_root, state_key)
158
+ except Exception:
159
+ # best effort; do not block writes
160
+ pass
161
+ return path.read_text(encoding="utf-8")
162
+
163
+
164
+ def read_workspace_doc(repo_root: Path, kind: str) -> str:
165
+ path = workspace_doc_path(repo_root, kind)
166
+ if not path.exists():
167
+ return ""
168
+ return path.read_text(encoding="utf-8")
169
+
170
+
171
+ def write_workspace_doc( # codeql[py/path-injection]
172
+ repo_root: Path, kind: str, content: str
173
+ ) -> str:
174
+ path = workspace_doc_path(repo_root, kind)
175
+ path.parent.mkdir(parents=True, exist_ok=True)
176
+ path.write_text(
177
+ content or "", encoding="utf-8"
178
+ ) # codeql[py/path-injection] workspace_doc_path is deterministic
179
+ try:
180
+ rel = path.relative_to(repo_root).as_posix()
181
+ draft_utils.invalidate_drafts_for_path(repo_root, rel)
182
+ state_key = f"workspace_{rel.replace('/', '_')}"
183
+ draft_utils.remove_draft(repo_root, state_key)
184
+ except Exception:
185
+ pass
186
+ return path.read_text(encoding="utf-8")
187
+
188
+
189
+ def _format_mtime(path: Path) -> str | None:
190
+ if not path.exists():
191
+ return None
192
+ ts = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
193
+ return ts.isoformat()
194
+
195
+
196
+ def list_workspace_files(
197
+ repo_root: Path,
198
+ ) -> list[WorkspaceFile]: # codeql[py/path-injection]
199
+ base = workspace_dir(repo_root)
200
+ base.mkdir(parents=True, exist_ok=True)
201
+
202
+ pinned: list[WorkspaceFile] = []
203
+ for kind in WORKSPACE_DOC_KINDS:
204
+ path = workspace_doc_path(repo_root, kind)
205
+ rel = path.relative_to(base).as_posix()
206
+ pinned.append(
207
+ WorkspaceFile(
208
+ name=path.name,
209
+ path=rel,
210
+ is_pinned=True,
211
+ modified_at=_format_mtime(path),
212
+ )
213
+ )
214
+
215
+ others: list[WorkspaceFile] = []
216
+ if base.exists():
217
+ for file_path in base.rglob("*"):
218
+ if file_path.is_dir():
219
+ continue
220
+ try:
221
+ rel = file_path.relative_to(base).as_posix()
222
+ except ValueError:
223
+ continue
224
+ if any(rel == pinned_file.path for pinned_file in pinned):
225
+ continue
226
+ others.append(
227
+ WorkspaceFile(
228
+ name=file_path.name,
229
+ path=rel,
230
+ is_pinned=False,
231
+ modified_at=_format_mtime(file_path),
232
+ )
233
+ )
234
+
235
+ others.sort(key=lambda f: f.path)
236
+ return [*pinned, *others]
237
+
238
+
239
+ def _sort_workspace_children(path: Path) -> tuple[int, str]:
240
+ # Folders first, then files, both alphabetized (case-insensitive)
241
+ return (0 if path.is_dir() else 1, path.name.lower())
242
+
243
+
244
+ def _is_within_workspace(base_real: Path, candidate: Path) -> bool:
245
+ try:
246
+ candidate.resolve().relative_to(base_real)
247
+ return True
248
+ except Exception:
249
+ return False
250
+
251
+
252
+ def _file_node(base: Path, path: Path, is_pinned: bool = False) -> WorkspaceNode:
253
+ rel = path.relative_to(base).as_posix()
254
+ size: int | None = None
255
+ if path.exists() and path.is_file():
256
+ try:
257
+ size = path.stat().st_size
258
+ except OSError:
259
+ size = None
260
+ return WorkspaceNode(
261
+ name=path.name,
262
+ path=rel,
263
+ type="file",
264
+ is_pinned=is_pinned,
265
+ modified_at=_format_mtime(path),
266
+ size=size,
267
+ )
268
+
269
+
270
+ def _build_workspace_tree(base: Path, path: Path) -> WorkspaceNode:
271
+ is_symlink = path.is_symlink()
272
+ is_folder = path.is_dir() and not is_symlink
273
+ is_pinned = path.name in PINNED_DOC_FILENAMES and path.parent == base
274
+
275
+ if not is_folder:
276
+ return _file_node(base, path, is_pinned=is_pinned)
277
+
278
+ children: list[WorkspaceNode] = []
279
+ for child in sorted(path.iterdir(), key=_sort_workspace_children):
280
+ # Avoid duplicating pinned docs surfaced at the root list
281
+ if child.parent == base and child.name in PINNED_DOC_FILENAMES:
282
+ continue
283
+ # Skip symlink escapes that resolve outside the workspace
284
+ if child.is_symlink() and not _is_within_workspace(base.resolve(), child):
285
+ continue
286
+ children.append(_build_workspace_tree(base, child))
287
+
288
+ return WorkspaceNode(
289
+ name=path.name,
290
+ path=path.relative_to(base).as_posix(),
291
+ type="folder",
292
+ is_pinned=False,
293
+ modified_at=_format_mtime(path),
294
+ children=children,
295
+ )
296
+
297
+
298
+ def list_workspace_tree(repo_root: Path) -> list[WorkspaceNode]:
299
+ """Return hierarchical workspace structure (folders + files)."""
300
+
301
+ base = workspace_dir(repo_root)
302
+ base.mkdir(parents=True, exist_ok=True)
303
+ base_real = base.resolve()
304
+
305
+ nodes: list[WorkspaceNode] = []
306
+
307
+ # Pinned docs first (even if missing)
308
+ for name in sorted(PINNED_DOC_FILENAMES):
309
+ pinned_path = base / name
310
+ nodes.append(_file_node(base, pinned_path, is_pinned=True))
311
+
312
+ for child in sorted(base.iterdir(), key=_sort_workspace_children):
313
+ if child.parent == base and child.name in PINNED_DOC_FILENAMES:
314
+ continue
315
+ if child.is_symlink() and not _is_within_workspace(base_real, child):
316
+ continue
317
+ nodes.append(_build_workspace_tree(base, child))
318
+
319
+ return nodes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-autorunner
3
- Version: 0.1.1
3
+ Version: 1.0.0
4
4
  Summary: Codex autorunner CLI per DESIGN-V1
5
5
  Author: Codex
6
6
  License: MIT License
@@ -45,10 +45,12 @@ Requires-Dist: ptyprocess>=0.7
45
45
  Requires-Dist: python-multipart>=0.0.9
46
46
  Requires-Dist: python-dotenv>=1.0
47
47
  Requires-Dist: httpx>=0.27
48
+ Requires-Dist: tenacity>=8.0
48
49
  Provides-Extra: dev
49
50
  Requires-Dist: black==25.11.0; extra == "dev"
50
51
  Requires-Dist: mypy>=1.10; extra == "dev"
51
52
  Requires-Dist: pytest>=7.0; extra == "dev"
53
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
52
54
  Requires-Dist: pytest-timeout>=2.0; extra == "dev"
53
55
  Requires-Dist: ruff>=0.5.0; extra == "dev"
54
56
  Requires-Dist: types-PyYAML; extra == "dev"
@@ -63,11 +65,17 @@ Dynamic: license-file
63
65
  # codex-autorunner
64
66
  [![PyPI](https://img.shields.io/pypi/v/codex-autorunner.svg)](https://pypi.org/project/codex-autorunner/)
65
67
 
66
- An opinionated autorunner that uses the Codex CLI to work on large tasks via a simple loop. On each loop we feed the Codex instance the last one's final output along with core documents.
67
- 1. TODO - Tracks long-horizon tasks
68
- 2. PROGRESS - High level overview of what's been done already that may be relevant for future agents
69
- 3. OPINIONS - Guidelines for how we should approach implementation
70
- 4. SPEC - Source-of-truth requirements and scope for large features/projects
68
+ An opinionated autorunner that uses the Codex app-server as the primary execution backend with OpenCode support to work on large tasks via a simple loop. On each loop we feed the Codex instance the last one's final output along with core documents.
69
+
70
+ In the current model, the primary work surface is **tickets**:
71
+
72
+ - `.codex-autorunner/tickets/TICKET-###.md`
73
+
74
+ Optionally, you can maintain lightweight **workspace docs** (auto-created on write; missing is OK):
75
+
76
+ - `.codex-autorunner/workspace/active_context.md`
77
+ - `.codex-autorunner/workspace/decisions.md`
78
+ - `.codex-autorunner/workspace/spec.md`
71
79
 
72
80
  ## Sneak Peak
73
81
  Run multiple agents on many repositories, with git worktree support
@@ -79,8 +87,7 @@ See the progress of your long running tasks with a high level overview
79
87
  Dive deep into specific agent execution with a rich but readable log
80
88
  ![Desktop logs](docs/screenshots/car-desktop-logs.png)
81
89
 
82
- All memory and opinions are markdown files! Edit them directly or chat with the document!
83
- ![Desktop TODO](docs/screenshots/car-desktop-todo.png)
90
+ Tickets and workspace docs are markdown files. Edit them directly or use the web UI’s file chat to iterate with the agent.
84
91
 
85
92
  Use codex CLI directly for multi-shot problem solving or `/review`
86
93
  ![Desktop terminal](docs/screenshots/car-desktop-terminal.png)
@@ -90,11 +97,10 @@ Mobile-first experience, code on the go with Whisper support (BYOK)
90
97
 
91
98
  ## What it does
92
99
  - Initializes a repo with Codex-friendly docs and config.
93
- - Runs Codex in a loop against the repo, streaming logs.
100
+ - Runs Codex app-server in a loop against the repo, streaming logs via OpenCode runtime.
94
101
  - Tracks state, logs, and config under `.codex-autorunner/`.
95
- - Exposes a power-user HTTP API and web UI for docs, logs, runner control, and a Codex TUI terminal.
102
+ - Exposes a power-user HTTP API and web UI for tickets, workspace docs, file chat, logs, runner control, and a Codex TUI terminal.
96
103
  - Optionally runs a Telegram bot for interactive, user-in-the-loop Codex sessions.
97
- - Generates a pasteable repo snapshot (`.codex-autorunner/SNAPSHOT.md`) for sharing with other LLM chats.
98
104
 
99
105
  CLI commands are available as `codex-autorunner` or the shorter `car`.
100
106
 
@@ -164,7 +170,7 @@ If you set a base path, prefix all checks with it.
164
170
 
165
171
  ## Quick start
166
172
  1) Install (editable): `pip install -e .`
167
- 2) Initialize (hub + repo): `codex-autorunner init --git-init` (or `car init --git-init` if you prefer short). This creates the hub config at `.codex-autorunner/config.yml`, plus state/log files and the docs under `.codex-autorunner/`.
173
+ 2) Initialize (hub + repo): `codex-autorunner init --git-init` (or `car init --git-init` if you prefer short). This creates the hub config at `.codex-autorunner/config.yml`, plus state/log files and starter content under `.codex-autorunner/` (tickets and optional workspace docs).
168
174
  3) Run once: `codex-autorunner once` / `car once`
169
175
  4) Continuous loop: `codex-autorunner run` / `car run`
170
176
  5) If stuck: `codex-autorunner kill` then `codex-autorunner resume` (or the `car` equivalents)
@@ -229,19 +235,12 @@ If you set `server.auth_token_env`, the API requires `Authorization: Bearer <tok
229
235
  - `run` / `once` — run the loop (continuous or single iteration).
230
236
  - `resume` — clear stale lock/state and restart; `--once` for a single run.
231
237
  - `kill` — SIGTERM the running loop and mark state error.
232
- - `status` — show current state and outstanding TODO count.
238
+ - `status` — show current state.
233
239
  - `sessions` — list terminal sessions (server-backed when available).
234
240
  - `stop-session` — stop a terminal session by repo (`--repo`) or id (`--session`).
235
241
  - `log` — view logs (tail or specific run).
236
- - `edit` — open TODO/PROGRESS/OPINIONS/SPEC in `$EDITOR`.
237
- - `ingest-spec` — generate TODO/PROGRESS/OPINIONS from SPEC using Codex (use `--force` to overwrite).
238
- - `clear-docs` — reset TODO/PROGRESS/OPINIONS to empty templates (type CLEAR to confirm).
239
- - `snapshot` — generate/update `.codex-autorunner/SNAPSHOT.md` (incremental by default when one exists; use `--from-scratch` to regenerate).
242
+ - `edit` — open `active_context|decisions|spec` in `$EDITOR`.
240
243
  - `serve` — start the HTTP API (FastAPI) on host/port from config (defaults 127.0.0.1:4173).
241
244
 
242
- ## Snapshot (repo briefing)
243
- - Web UI: open the Snapshot tab. If no snapshot exists, you’ll see “Generate snapshot”; otherwise you’ll see “Update snapshot (incremental)” and “Regenerate snapshot (from scratch)”, plus “Copy to clipboard”.
244
- - CLI: `codex-autorunner snapshot` (or `car snapshot`) writes `.codex-autorunner/SNAPSHOT.md` and `.codex-autorunner/snapshot_state.json`.
245
-
246
245
  ## Star history
247
246
  [![Star History Chart](https://api.star-history.com/svg?repos=Git-on-my-level/codex-autorunner&type=Date)](https://star-history.com/#Git-on-my-level/codex-autorunner&Date)