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
@@ -1,6 +1,7 @@
1
1
  import dataclasses
2
2
  import ipaddress
3
3
  import json
4
+ import logging
4
5
  import os
5
6
  import shlex
6
7
  from os import PathLike
@@ -10,6 +11,9 @@ from typing import IO, Any, Dict, List, Mapping, Optional, Union, cast
10
11
  import yaml
11
12
 
12
13
  from ..housekeeping import HousekeepingConfig, parse_housekeeping_config
14
+ from .path_utils import ConfigPathError, resolve_config_path
15
+
16
+ logger = logging.getLogger("codex_autorunner.core.config")
13
17
 
14
18
  DOTENV_AVAILABLE = True
15
19
  try:
@@ -48,13 +52,18 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
48
52
  "version": CONFIG_VERSION,
49
53
  "mode": "repo",
50
54
  "docs": {
51
- "todo": ".codex-autorunner/TODO.md",
52
- "progress": ".codex-autorunner/PROGRESS.md",
53
- "opinions": ".codex-autorunner/OPINIONS.md",
54
- "spec": ".codex-autorunner/SPEC.md",
55
- "summary": ".codex-autorunner/SUMMARY.md",
56
- "snapshot": ".codex-autorunner/SNAPSHOT.md",
57
- "snapshot_state": ".codex-autorunner/snapshot_state.json",
55
+ "active_context": ".codex-autorunner/workspace/active_context.md",
56
+ "decisions": ".codex-autorunner/workspace/decisions.md",
57
+ "spec": ".codex-autorunner/workspace/spec.md",
58
+ },
59
+ "review": {
60
+ "enabled": True,
61
+ "agent": "opencode",
62
+ "model": "zai-coding-plan/glm-4.7",
63
+ "subagent_agent": "subagent",
64
+ "subagent_model": "zai-coding-plan/glm-4.7-flashx",
65
+ "reasoning": None,
66
+ "max_wallclock_seconds": None,
58
67
  },
59
68
  "codex": {
60
69
  "binary": "codex",
@@ -77,16 +86,52 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
77
86
  },
78
87
  "opencode": {
79
88
  "binary": "opencode",
89
+ "subagent_models": {
90
+ "subagent": "zai-coding-plan/glm-4.7-flashx",
91
+ },
80
92
  },
81
93
  },
82
94
  "prompt": {
83
95
  "prev_run_max_chars": 6000,
84
96
  "template": ".codex-autorunner/prompt.txt",
85
97
  },
98
+ "security": {
99
+ "redact_run_logs": True,
100
+ },
86
101
  "runner": {
87
102
  "sleep_seconds": 5,
88
103
  "stop_after_runs": None,
89
104
  "max_wallclock_seconds": None,
105
+ "no_progress_threshold": 3,
106
+ "review": {
107
+ "enabled": False,
108
+ "trigger": {
109
+ "on_todos_complete": True,
110
+ "on_no_progress_stop": True,
111
+ "on_max_runs_stop": True,
112
+ "on_stop_requested": False,
113
+ "on_error_exit": False,
114
+ },
115
+ "agent": None,
116
+ "model": None,
117
+ "reasoning": None,
118
+ "max_wallclock_seconds": None,
119
+ "context": {
120
+ "primary_docs": ["spec", "decisions"],
121
+ "include_docs": ["active_context"],
122
+ "include_last_run_artifacts": True,
123
+ "max_doc_chars": 20000,
124
+ },
125
+ "artifacts": {
126
+ "attach_to_last_run_index": True,
127
+ "write_to_review_runs_dir": True,
128
+ },
129
+ },
130
+ },
131
+ "ticket_flow": {
132
+ "approval_mode": "yolo",
133
+ # Keep ticket_flow deterministic by default; surfaces can tighten this.
134
+ "default_approval_decision": "accept",
90
135
  },
91
136
  "git": {
92
137
  "auto_commit": False,
@@ -98,34 +143,33 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
98
143
  "sync_commit_mode": "auto", # none|auto|always
99
144
  # Bounds the agentic sync step in GitHubService.sync_pr (seconds).
100
145
  "sync_agent_timeout_seconds": 1800,
101
- "pr_flow": {
102
- "enabled": True,
103
- "max_cycles": 3,
104
- "stop_condition": "no_issues",
105
- "max_implementation_runs": None,
106
- "max_wallclock_seconds": None,
107
- "review": {
108
- "include_codex": True,
109
- "include_github": True,
110
- "include_checks": True,
111
- },
112
- "chatops": {
113
- "enabled": False,
114
- "poll_interval_seconds": 60,
115
- "allow_users": [],
116
- "allow_associations": [],
117
- "ignore_bots": True,
118
- },
119
- },
146
+ },
147
+ "update": {
148
+ "skip_checks": False,
120
149
  },
121
150
  "app_server": {
122
151
  "command": ["codex", "app-server"],
123
152
  "state_root": "~/.codex-autorunner/workspaces",
153
+ "auto_restart": True,
124
154
  "max_handles": 20,
125
155
  "idle_ttl_seconds": 3600,
126
156
  "turn_timeout_seconds": 28800,
157
+ "turn_stall_timeout_seconds": 60,
158
+ "turn_stall_poll_interval_seconds": 2,
159
+ "turn_stall_recovery_min_interval_seconds": 10,
127
160
  "request_timeout": None,
161
+ "client": {
162
+ "max_message_bytes": 50 * 1024 * 1024,
163
+ "oversize_preview_bytes": 4096,
164
+ "max_oversize_drain_bytes": 100 * 1024 * 1024,
165
+ "restart_backoff_initial_seconds": 0.5,
166
+ "restart_backoff_max_seconds": 30.0,
167
+ "restart_backoff_jitter_ratio": 0.1,
168
+ },
128
169
  "prompts": {
170
+ # NOTE: These keys are legacy names kept for config compatibility.
171
+ # The workspace cutover uses tickets + workspace docs + unified file chat; only
172
+ # the "autorunner" prompt is currently used by the app-server prompt builder.
129
173
  "doc_chat": {
130
174
  "max_chars": 12000,
131
175
  "message_max_chars": 2000,
@@ -145,6 +189,9 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
145
189
  },
146
190
  },
147
191
  },
192
+ "opencode": {
193
+ "session_stall_timeout_seconds": 60,
194
+ },
148
195
  "server": {
149
196
  "host": "127.0.0.1",
150
197
  "port": 4173,
@@ -208,6 +255,20 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
208
255
  "timeout_ms": 120000,
209
256
  "max_output_chars": 3800,
210
257
  },
258
+ "cache": {
259
+ "cleanup_interval_seconds": 300,
260
+ "coalesce_buffer_ttl_seconds": 60,
261
+ "media_batch_buffer_ttl_seconds": 60,
262
+ "model_pending_ttl_seconds": 1800,
263
+ "pending_approval_ttl_seconds": 600,
264
+ "pending_question_ttl_seconds": 600,
265
+ "reasoning_buffer_ttl_seconds": 900,
266
+ "selection_state_ttl_seconds": 1800,
267
+ "turn_preview_ttl_seconds": 900,
268
+ "progress_stream_ttl_seconds": 900,
269
+ "oversize_warning_ttl_seconds": 3600,
270
+ "update_id_persist_interval_seconds": 60,
271
+ },
211
272
  "command_registration": {
212
273
  "enabled": True,
213
274
  "scopes": [
@@ -215,12 +276,18 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
215
276
  {"type": "all_group_chats", "language_code": ""},
216
277
  ],
217
278
  },
218
- "state_file": ".codex-autorunner/telegram_state.json",
279
+ "opencode_command": None,
280
+ "state_file": ".codex-autorunner/telegram_state.sqlite3",
219
281
  "app_server_command_env": "CAR_TELEGRAM_APP_SERVER_COMMAND",
220
282
  "app_server_command": ["codex", "app-server"],
221
283
  "app_server": {
222
284
  "max_handles": 20,
223
285
  "idle_ttl_seconds": 3600,
286
+ "turn_timeout_seconds": 28800,
287
+ },
288
+ "agent_timeouts": {
289
+ "codex": 28800,
290
+ "opencode": 28800,
224
291
  },
225
292
  "polling": {
226
293
  "timeout_seconds": 30,
@@ -334,6 +401,16 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
334
401
  "max_total_bytes": 100_000_000,
335
402
  "max_age_days": 30,
336
403
  },
404
+ {
405
+ "name": "review_runs",
406
+ "kind": "directory",
407
+ "path": ".codex-autorunner/review/runs",
408
+ "glob": "*",
409
+ "recursive": True,
410
+ "max_files": 100,
411
+ "max_total_bytes": 500_000_000,
412
+ "max_age_days": 30,
413
+ },
337
414
  ],
338
415
  },
339
416
  }
@@ -343,12 +420,16 @@ REPO_DEFAULT_KEYS = {
343
420
  "codex",
344
421
  "prompt",
345
422
  "runner",
423
+ "ticket_flow",
346
424
  "git",
347
425
  "github",
426
+ "update",
348
427
  "notifications",
349
428
  "voice",
350
429
  "log",
351
430
  "server_log",
431
+ "review",
432
+ "opencode",
352
433
  }
353
434
  DEFAULT_REPO_DEFAULTS = {
354
435
  key: json.loads(json.dumps(DEFAULT_REPO_CONFIG[key])) for key in REPO_DEFAULT_KEYS
@@ -357,10 +438,12 @@ REPO_SHARED_KEYS = {
357
438
  "agents",
358
439
  "server",
359
440
  "app_server",
441
+ "opencode",
360
442
  "telegram_bot",
361
443
  "terminal",
362
444
  "static_assets",
363
445
  "housekeeping",
446
+ "update",
364
447
  }
365
448
 
366
449
  DEFAULT_HUB_CONFIG: Dict[str, Any] = {
@@ -373,6 +456,9 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
373
456
  },
374
457
  "opencode": {
375
458
  "binary": "opencode",
459
+ "subagent_models": {
460
+ "subagent": "zai-coding-plan/glm-4.7-flashx",
461
+ },
376
462
  },
377
463
  },
378
464
  "terminal": {
@@ -420,6 +506,20 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
420
506
  "timeout_ms": 120000,
421
507
  "max_output_chars": 3800,
422
508
  },
509
+ "cache": {
510
+ "cleanup_interval_seconds": 300,
511
+ "coalesce_buffer_ttl_seconds": 60,
512
+ "media_batch_buffer_ttl_seconds": 60,
513
+ "model_pending_ttl_seconds": 1800,
514
+ "pending_approval_ttl_seconds": 600,
515
+ "pending_question_ttl_seconds": 600,
516
+ "reasoning_buffer_ttl_seconds": 900,
517
+ "selection_state_ttl_seconds": 1800,
518
+ "turn_preview_ttl_seconds": 900,
519
+ "progress_stream_ttl_seconds": 900,
520
+ "oversize_warning_ttl_seconds": 3600,
521
+ "update_id_persist_interval_seconds": 60,
522
+ },
423
523
  "command_registration": {
424
524
  "enabled": True,
425
525
  "scopes": [
@@ -427,12 +527,14 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
427
527
  {"type": "all_group_chats", "language_code": ""},
428
528
  ],
429
529
  },
430
- "state_file": ".codex-autorunner/telegram_state.json",
530
+ "opencode_command": None,
531
+ "state_file": ".codex-autorunner/telegram_state.sqlite3",
431
532
  "app_server_command_env": "CAR_TELEGRAM_APP_SERVER_COMMAND",
432
533
  "app_server_command": ["codex", "app-server"],
433
534
  "app_server": {
434
535
  "max_handles": 20,
435
536
  "idle_ttl_seconds": 3600,
537
+ "turn_timeout_seconds": 28800,
436
538
  },
437
539
  "polling": {
438
540
  "timeout_seconds": 30,
@@ -456,13 +558,28 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
456
558
  "backup_count": 3,
457
559
  },
458
560
  },
561
+ "update": {
562
+ "skip_checks": False,
563
+ },
459
564
  "app_server": {
460
565
  "command": ["codex", "app-server"],
461
566
  "state_root": "~/.codex-autorunner/workspaces",
567
+ "auto_restart": True,
462
568
  "max_handles": 20,
463
569
  "idle_ttl_seconds": 3600,
464
570
  "turn_timeout_seconds": 28800,
571
+ "turn_stall_timeout_seconds": 60,
572
+ "turn_stall_poll_interval_seconds": 2,
573
+ "turn_stall_recovery_min_interval_seconds": 10,
465
574
  "request_timeout": None,
575
+ "client": {
576
+ "max_message_bytes": 50 * 1024 * 1024,
577
+ "oversize_preview_bytes": 4096,
578
+ "max_oversize_drain_bytes": 100 * 1024 * 1024,
579
+ "restart_backoff_initial_seconds": 0.5,
580
+ "restart_backoff_max_seconds": 30.0,
581
+ "restart_backoff_jitter_ratio": 0.1,
582
+ },
466
583
  "prompts": {
467
584
  "doc_chat": {
468
585
  "max_chars": 12000,
@@ -483,6 +600,9 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
483
600
  },
484
601
  },
485
602
  },
603
+ "opencode": {
604
+ "session_stall_timeout_seconds": 60,
605
+ },
486
606
  "server": {
487
607
  "host": "127.0.0.1",
488
608
  "port": 4173,
@@ -590,6 +710,12 @@ class ConfigError(Exception):
590
710
  """Raised when configuration is invalid."""
591
711
 
592
712
 
713
+ __all__ = [
714
+ "ConfigError",
715
+ "ConfigPathError",
716
+ ]
717
+
718
+
593
719
  @dataclasses.dataclass
594
720
  class LogConfig:
595
721
  path: Path
@@ -634,21 +760,43 @@ class AppServerPromptsConfig:
634
760
  autorunner: AppServerAutorunnerPromptConfig
635
761
 
636
762
 
763
+ @dataclasses.dataclass
764
+ class AppServerClientConfig:
765
+ max_message_bytes: int
766
+ oversize_preview_bytes: int
767
+ max_oversize_drain_bytes: int
768
+ restart_backoff_initial_seconds: float
769
+ restart_backoff_max_seconds: float
770
+ restart_backoff_jitter_ratio: float
771
+
772
+
637
773
  @dataclasses.dataclass
638
774
  class AppServerConfig:
639
775
  command: List[str]
640
776
  state_root: Path
777
+ auto_restart: Optional[bool]
641
778
  max_handles: Optional[int]
642
779
  idle_ttl_seconds: Optional[int]
643
780
  turn_timeout_seconds: Optional[float]
781
+ turn_stall_timeout_seconds: Optional[float]
782
+ turn_stall_poll_interval_seconds: Optional[float]
783
+ turn_stall_recovery_min_interval_seconds: Optional[float]
644
784
  request_timeout: Optional[float]
785
+ client: AppServerClientConfig
645
786
  prompts: AppServerPromptsConfig
646
787
 
647
788
 
789
+ @dataclasses.dataclass
790
+ class OpenCodeConfig:
791
+ session_stall_timeout_seconds: Optional[float]
792
+
793
+
648
794
  @dataclasses.dataclass(frozen=True)
649
795
  class AgentConfig:
650
796
  binary: str
651
797
  serve_command: Optional[List[str]]
798
+ base_url: Optional[str]
799
+ subagent_models: Optional[Dict[str, str]]
652
800
 
653
801
 
654
802
  @dataclasses.dataclass
@@ -657,6 +805,7 @@ class RepoConfig:
657
805
  root: Path
658
806
  version: int
659
807
  mode: str
808
+ security: Dict[str, Any]
660
809
  docs: Dict[str, Path]
661
810
  codex_binary: str
662
811
  codex_args: List[str]
@@ -669,9 +818,13 @@ class RepoConfig:
669
818
  runner_sleep_seconds: int
670
819
  runner_stop_after_runs: Optional[int]
671
820
  runner_max_wallclock_seconds: Optional[int]
821
+ runner_no_progress_threshold: int
822
+ ticket_flow: Dict[str, Any]
672
823
  git_auto_commit: bool
673
824
  git_commit_message_template: str
825
+ update_skip_checks: bool
674
826
  app_server: AppServerConfig
827
+ opencode: OpenCodeConfig
675
828
  server_host: str
676
829
  server_port: int
677
830
  server_base_path: str
@@ -719,7 +872,9 @@ class HubConfig:
719
872
  repo_server_inherit: bool
720
873
  update_repo_url: str
721
874
  update_repo_ref: str
875
+ update_skip_checks: bool
722
876
  app_server: AppServerConfig
877
+ opencode: OpenCodeConfig
723
878
  server_host: str
724
879
  server_port: int
725
880
  server_base_path: str
@@ -950,9 +1105,19 @@ def _parse_app_server_config(
950
1105
  cfg = cfg if isinstance(cfg, dict) else {}
951
1106
  command = _parse_command(cfg.get("command", defaults.get("command")))
952
1107
  state_root_raw = cfg.get("state_root", defaults.get("state_root"))
953
- state_root = Path(str(state_root_raw)).expanduser()
954
- if not state_root.is_absolute():
955
- state_root = root / state_root
1108
+ if state_root_raw is None:
1109
+ raise ConfigError("app_server.state_root is required")
1110
+ state_root = resolve_config_path(
1111
+ state_root_raw,
1112
+ root,
1113
+ allow_home=True,
1114
+ scope="app_server.state_root",
1115
+ )
1116
+ auto_restart_raw = cfg.get("auto_restart", defaults.get("auto_restart"))
1117
+ if auto_restart_raw is None:
1118
+ auto_restart = None
1119
+ else:
1120
+ auto_restart = bool(auto_restart_raw)
956
1121
  max_handles_raw = cfg.get("max_handles", defaults.get("max_handles"))
957
1122
  max_handles = int(max_handles_raw) if max_handles_raw is not None else None
958
1123
  if max_handles is not None and max_handles <= 0:
@@ -969,29 +1134,119 @@ def _parse_app_server_config(
969
1134
  )
970
1135
  if turn_timeout_seconds is not None and turn_timeout_seconds <= 0:
971
1136
  turn_timeout_seconds = None
1137
+ stall_timeout_raw = cfg.get(
1138
+ "turn_stall_timeout_seconds", defaults.get("turn_stall_timeout_seconds")
1139
+ )
1140
+ turn_stall_timeout_seconds = (
1141
+ float(stall_timeout_raw) if stall_timeout_raw is not None else None
1142
+ )
1143
+ if turn_stall_timeout_seconds is not None and turn_stall_timeout_seconds <= 0:
1144
+ turn_stall_timeout_seconds = None
1145
+ stall_poll_raw = cfg.get(
1146
+ "turn_stall_poll_interval_seconds",
1147
+ defaults.get("turn_stall_poll_interval_seconds"),
1148
+ )
1149
+ turn_stall_poll_interval_seconds = (
1150
+ float(stall_poll_raw) if stall_poll_raw is not None else None
1151
+ )
1152
+ if (
1153
+ turn_stall_poll_interval_seconds is not None
1154
+ and turn_stall_poll_interval_seconds <= 0
1155
+ ):
1156
+ turn_stall_poll_interval_seconds = defaults.get(
1157
+ "turn_stall_poll_interval_seconds"
1158
+ )
1159
+ stall_recovery_raw = cfg.get(
1160
+ "turn_stall_recovery_min_interval_seconds",
1161
+ defaults.get("turn_stall_recovery_min_interval_seconds"),
1162
+ )
1163
+ turn_stall_recovery_min_interval_seconds = (
1164
+ float(stall_recovery_raw) if stall_recovery_raw is not None else None
1165
+ )
1166
+ if (
1167
+ turn_stall_recovery_min_interval_seconds is not None
1168
+ and turn_stall_recovery_min_interval_seconds < 0
1169
+ ):
1170
+ turn_stall_recovery_min_interval_seconds = defaults.get(
1171
+ "turn_stall_recovery_min_interval_seconds"
1172
+ )
972
1173
  request_timeout_raw = cfg.get("request_timeout", defaults.get("request_timeout"))
973
1174
  request_timeout = (
974
1175
  float(request_timeout_raw) if request_timeout_raw is not None else None
975
1176
  )
976
1177
  if request_timeout is not None and request_timeout <= 0:
977
1178
  request_timeout = None
1179
+ client_defaults = defaults.get("client")
1180
+ client_defaults = client_defaults if isinstance(client_defaults, dict) else {}
1181
+ client_cfg_raw = cfg.get("client")
1182
+ client_cfg = client_cfg_raw if isinstance(client_cfg_raw, dict) else {}
1183
+
1184
+ def _client_int(key: str) -> int:
1185
+ value = client_cfg.get(key, client_defaults.get(key))
1186
+ value = int(value) if value is not None else 0
1187
+ if value <= 0:
1188
+ value = int(client_defaults.get(key) or 0)
1189
+ return value
1190
+
1191
+ def _client_float(key: str, *, allow_zero: bool = False) -> float:
1192
+ value = client_cfg.get(key, client_defaults.get(key))
1193
+ value = float(value) if value is not None else 0.0
1194
+ if value < 0 or (not allow_zero and value <= 0):
1195
+ value = float(client_defaults.get(key) or 0.0)
1196
+ return value
1197
+
978
1198
  prompt_defaults = defaults.get("prompts")
979
1199
  prompts = _parse_app_server_prompts_config(cfg.get("prompts"), prompt_defaults)
980
1200
  return AppServerConfig(
981
1201
  command=command,
982
1202
  state_root=state_root,
1203
+ auto_restart=auto_restart,
983
1204
  max_handles=max_handles,
984
1205
  idle_ttl_seconds=idle_ttl_seconds,
985
1206
  turn_timeout_seconds=turn_timeout_seconds,
1207
+ turn_stall_timeout_seconds=turn_stall_timeout_seconds,
1208
+ turn_stall_poll_interval_seconds=turn_stall_poll_interval_seconds,
1209
+ turn_stall_recovery_min_interval_seconds=turn_stall_recovery_min_interval_seconds,
986
1210
  request_timeout=request_timeout,
1211
+ client=AppServerClientConfig(
1212
+ max_message_bytes=_client_int("max_message_bytes"),
1213
+ oversize_preview_bytes=_client_int("oversize_preview_bytes"),
1214
+ max_oversize_drain_bytes=_client_int("max_oversize_drain_bytes"),
1215
+ restart_backoff_initial_seconds=_client_float(
1216
+ "restart_backoff_initial_seconds"
1217
+ ),
1218
+ restart_backoff_max_seconds=_client_float("restart_backoff_max_seconds"),
1219
+ restart_backoff_jitter_ratio=_client_float(
1220
+ "restart_backoff_jitter_ratio", allow_zero=True
1221
+ ),
1222
+ ),
987
1223
  prompts=prompts,
988
1224
  )
989
1225
 
990
1226
 
1227
+ def _parse_opencode_config(
1228
+ cfg: Optional[Dict[str, Any]],
1229
+ _root: Path,
1230
+ defaults: Optional[Dict[str, Any]],
1231
+ ) -> OpenCodeConfig:
1232
+ cfg = cfg if isinstance(cfg, dict) else {}
1233
+ defaults = defaults if isinstance(defaults, dict) else {}
1234
+ stall_timeout_raw = cfg.get(
1235
+ "session_stall_timeout_seconds",
1236
+ defaults.get("session_stall_timeout_seconds"),
1237
+ )
1238
+ stall_timeout_seconds = (
1239
+ float(stall_timeout_raw) if stall_timeout_raw is not None else None
1240
+ )
1241
+ if stall_timeout_seconds is not None and stall_timeout_seconds <= 0:
1242
+ stall_timeout_seconds = None
1243
+ return OpenCodeConfig(session_stall_timeout_seconds=stall_timeout_seconds)
1244
+
1245
+
991
1246
  def _parse_agents_config(
992
- cfg: Dict[str, Any], defaults: Dict[str, Any]
1247
+ cfg: Optional[Dict[str, Any]], defaults: Dict[str, Any]
993
1248
  ) -> Dict[str, AgentConfig]:
994
- raw_agents = cfg.get("agents")
1249
+ raw_agents = cfg.get("agents") if cfg else None
995
1250
  if not isinstance(raw_agents, dict):
996
1251
  raw_agents = defaults.get("agents", {})
997
1252
  agents: Dict[str, AgentConfig] = {}
@@ -1004,9 +1259,15 @@ def _parse_agents_config(
1004
1259
  serve_command = None
1005
1260
  if "serve_command" in agent_cfg:
1006
1261
  serve_command = _parse_command(agent_cfg.get("serve_command"))
1262
+ base_url = agent_cfg.get("base_url")
1263
+ subagent_models = agent_cfg.get("subagent_models")
1264
+ if not isinstance(subagent_models, dict):
1265
+ subagent_models = None
1007
1266
  agents[str(agent_id)] = AgentConfig(
1008
- binary=str(binary),
1009
- serve_command=serve_command if serve_command else None,
1267
+ binary=binary,
1268
+ serve_command=serve_command,
1269
+ base_url=base_url,
1270
+ subagent_models=subagent_models,
1010
1271
  )
1011
1272
  return agents
1012
1273
 
@@ -1019,9 +1280,14 @@ def _parse_static_assets_config(
1019
1280
  if not isinstance(cfg, dict):
1020
1281
  cfg = defaults
1021
1282
  cache_root_raw = cfg.get("cache_root", defaults.get("cache_root"))
1022
- cache_root = Path(str(cache_root_raw))
1023
- if not cache_root.is_absolute():
1024
- cache_root = root / cache_root
1283
+ if cache_root_raw is None:
1284
+ raise ConfigError("static_assets.cache_root is required")
1285
+ cache_root = resolve_config_path(
1286
+ cache_root_raw,
1287
+ root,
1288
+ allow_home=True,
1289
+ scope="static_assets.cache_root",
1290
+ )
1025
1291
  max_cache_entries = int(
1026
1292
  cfg.get("max_cache_entries", defaults.get("max_cache_entries", 0))
1027
1293
  )
@@ -1057,9 +1323,8 @@ def load_dotenv_for_root(root: Path) -> None:
1057
1323
  # Prefer repo-local .env over inherited process env to avoid stale keys
1058
1324
  # (common when running via launchd/daemon or with a global shell export).
1059
1325
  load_dotenv(dotenv_path=candidate, override=True)
1060
- except Exception:
1061
- # Never fail config loading due to dotenv issues.
1062
- pass
1326
+ except OSError as exc:
1327
+ logger.debug("Failed to load .env file: %s", exc)
1063
1328
 
1064
1329
 
1065
1330
  def _parse_dotenv_fallback(path: Path) -> Dict[str, str]:
@@ -1126,6 +1391,21 @@ def load_hub_config_data(config_path: Path) -> Dict[str, Any]:
1126
1391
 
1127
1392
  def _resolve_hub_config_path(start: Path) -> Path:
1128
1393
  config_path = find_nearest_hub_config_path(start)
1394
+ if not config_path:
1395
+ # Auto-initialize hub config if missing in the current directory or parents.
1396
+ # If we are in a git repo, we'll initialize a hub there.
1397
+ try:
1398
+ from .utils import find_repo_root
1399
+
1400
+ target_root = find_repo_root(start)
1401
+ except Exception:
1402
+ target_root = start
1403
+
1404
+ from ..bootstrap import seed_hub_files
1405
+
1406
+ seed_hub_files(target_root)
1407
+ config_path = find_nearest_hub_config_path(target_root)
1408
+
1129
1409
  if not config_path:
1130
1410
  raise ConfigError(
1131
1411
  f"Missing hub config file; expected to find {CONFIG_FILENAME} in {start} or parents (use --hub to specify)"
@@ -1171,7 +1451,7 @@ def derive_repo_config(
1171
1451
  def _resolve_repo_root(start: Path) -> Path:
1172
1452
  search_dir = start.resolve() if start.is_dir() else start.resolve().parent
1173
1453
  for current in [search_dir] + list(search_dir.parents):
1174
- if (current / ".codex-autorunner" / "state.json").exists():
1454
+ if (current / ".codex-autorunner" / "state.sqlite3").exists():
1175
1455
  return current
1176
1456
  if (current / ".git").exists():
1177
1457
  return current
@@ -1191,15 +1471,9 @@ def load_repo_config(start: Path, hub_path: Optional[Path] = None) -> RepoConfig
1191
1471
  def _build_repo_config(config_path: Path, cfg: Dict[str, Any]) -> RepoConfig:
1192
1472
  root = config_path.parent.parent.resolve()
1193
1473
  docs = {
1194
- "todo": Path(cfg["docs"]["todo"]),
1195
- "progress": Path(cfg["docs"]["progress"]),
1196
- "opinions": Path(cfg["docs"]["opinions"]),
1474
+ "active_context": Path(cfg["docs"]["active_context"]),
1475
+ "decisions": Path(cfg["docs"]["decisions"]),
1197
1476
  "spec": Path(cfg["docs"]["spec"]),
1198
- "summary": Path(cfg["docs"]["summary"]),
1199
- "snapshot": Path(cfg["docs"].get("snapshot", ".codex-autorunner/SNAPSHOT.md")),
1200
- "snapshot_state": Path(
1201
- cfg["docs"].get("snapshot_state", ".codex-autorunner/snapshot_state.json")
1202
- ),
1203
1477
  }
1204
1478
  voice_cfg = cfg.get("voice") if isinstance(cfg.get("voice"), dict) else {}
1205
1479
  voice_cfg = cast(Dict[str, Any], voice_cfg)
@@ -1220,12 +1494,19 @@ def _build_repo_config(config_path: Path, cfg: Dict[str, Any]) -> RepoConfig:
1220
1494
  cfg.get("notifications") if isinstance(cfg.get("notifications"), dict) else {}
1221
1495
  )
1222
1496
  notifications_cfg = cast(Dict[str, Any], notifications_cfg)
1497
+ security_cfg = cfg.get("security") if isinstance(cfg.get("security"), dict) else {}
1498
+ security_cfg = cast(Dict[str, Any], security_cfg)
1223
1499
  log_cfg = cfg.get("log", {})
1224
1500
  log_cfg = cast(Dict[str, Any], log_cfg if isinstance(log_cfg, dict) else {})
1225
1501
  server_log_cfg = cfg.get("server_log", {}) or {}
1226
1502
  server_log_cfg = cast(
1227
1503
  Dict[str, Any], server_log_cfg if isinstance(server_log_cfg, dict) else {}
1228
1504
  )
1505
+ update_cfg = cfg.get("update")
1506
+ update_cfg = cast(
1507
+ Dict[str, Any], update_cfg if isinstance(update_cfg, dict) else {}
1508
+ )
1509
+ update_skip_checks = bool(update_cfg.get("skip_checks", False))
1229
1510
  return RepoConfig(
1230
1511
  raw=cfg,
1231
1512
  root=root,
@@ -1243,13 +1524,20 @@ def _build_repo_config(config_path: Path, cfg: Dict[str, Any]) -> RepoConfig:
1243
1524
  runner_sleep_seconds=int(cfg["runner"]["sleep_seconds"]),
1244
1525
  runner_stop_after_runs=cfg["runner"].get("stop_after_runs"),
1245
1526
  runner_max_wallclock_seconds=cfg["runner"].get("max_wallclock_seconds"),
1527
+ runner_no_progress_threshold=int(cfg["runner"].get("no_progress_threshold", 3)),
1246
1528
  git_auto_commit=bool(cfg["git"].get("auto_commit", False)),
1247
1529
  git_commit_message_template=str(cfg["git"].get("commit_message_template")),
1530
+ update_skip_checks=update_skip_checks,
1531
+ ticket_flow=cast(Dict[str, Any], cfg.get("ticket_flow") or {}),
1248
1532
  app_server=_parse_app_server_config(
1249
1533
  cfg.get("app_server"),
1250
1534
  root,
1251
1535
  DEFAULT_REPO_CONFIG["app_server"],
1252
1536
  ),
1537
+ opencode=_parse_opencode_config(
1538
+ cfg.get("opencode"), root, DEFAULT_REPO_CONFIG.get("opencode")
1539
+ ),
1540
+ security=security_cfg,
1253
1541
  server_host=str(cfg["server"].get("host")),
1254
1542
  server_port=int(cfg["server"].get("port")),
1255
1543
  server_base_path=_normalize_base_path(cfg["server"].get("base_path", "")),
@@ -1303,6 +1591,29 @@ def _build_hub_config(config_path: Path, cfg: Dict[str, Any]) -> HubConfig:
1303
1591
  "max_bytes": log_cfg["max_bytes"],
1304
1592
  "backup_count": log_cfg["backup_count"],
1305
1593
  }
1594
+
1595
+ log_path_str = log_cfg["path"]
1596
+ try:
1597
+ log_path = resolve_config_path(log_path_str, root, scope="log.path")
1598
+ except ConfigPathError as exc:
1599
+ raise ConfigError(str(exc)) from exc
1600
+
1601
+ server_log_path_str = str(server_log_cfg.get("path", log_cfg["path"]))
1602
+ try:
1603
+ server_log_path = resolve_config_path(
1604
+ server_log_path_str,
1605
+ root,
1606
+ scope="server_log.path",
1607
+ )
1608
+ except ConfigPathError as exc:
1609
+ raise ConfigError(str(exc)) from exc
1610
+
1611
+ update_cfg = cfg.get("update")
1612
+ update_cfg = cast(
1613
+ Dict[str, Any], update_cfg if isinstance(update_cfg, dict) else {}
1614
+ )
1615
+ update_skip_checks = bool(update_cfg.get("skip_checks", False))
1616
+
1306
1617
  return HubConfig(
1307
1618
  raw=cfg,
1308
1619
  root=root,
@@ -1318,11 +1629,15 @@ def _build_hub_config(config_path: Path, cfg: Dict[str, Any]) -> HubConfig:
1318
1629
  repo_server_inherit=bool(hub_cfg.get("repo_server_inherit", True)),
1319
1630
  update_repo_url=str(hub_cfg.get("update_repo_url", "")),
1320
1631
  update_repo_ref=str(hub_cfg.get("update_repo_ref", "main")),
1632
+ update_skip_checks=update_skip_checks,
1321
1633
  app_server=_parse_app_server_config(
1322
1634
  cfg.get("app_server"),
1323
1635
  root,
1324
1636
  DEFAULT_HUB_CONFIG["app_server"],
1325
1637
  ),
1638
+ opencode=_parse_opencode_config(
1639
+ cfg.get("opencode"), root, DEFAULT_HUB_CONFIG.get("opencode")
1640
+ ),
1326
1641
  server_host=str(cfg["server"]["host"]),
1327
1642
  server_port=int(cfg["server"]["port"]),
1328
1643
  server_base_path=_normalize_base_path(cfg["server"].get("base_path", "")),
@@ -1331,12 +1646,12 @@ def _build_hub_config(config_path: Path, cfg: Dict[str, Any]) -> HubConfig:
1331
1646
  server_allowed_hosts=list(cfg["server"].get("allowed_hosts") or []),
1332
1647
  server_allowed_origins=list(cfg["server"].get("allowed_origins") or []),
1333
1648
  log=LogConfig(
1334
- path=root / log_cfg["path"],
1649
+ path=log_path,
1335
1650
  max_bytes=int(log_cfg["max_bytes"]),
1336
1651
  backup_count=int(log_cfg["backup_count"]),
1337
1652
  ),
1338
1653
  server_log=LogConfig(
1339
- path=root / str(server_log_cfg.get("path", log_cfg["path"])),
1654
+ path=server_log_path,
1340
1655
  max_bytes=int(server_log_cfg.get("max_bytes", log_cfg["max_bytes"])),
1341
1656
  backup_count=int(
1342
1657
  server_log_cfg.get("backup_count", log_cfg["backup_count"])
@@ -1402,6 +1717,12 @@ def _validate_app_server_config(cfg: Dict[str, Any]) -> None:
1402
1717
  app_server_cfg.get("state_root", ""), str
1403
1718
  ):
1404
1719
  raise ConfigError("app_server.state_root must be a string path")
1720
+ if (
1721
+ "auto_restart" in app_server_cfg
1722
+ and app_server_cfg.get("auto_restart") is not None
1723
+ ):
1724
+ if not isinstance(app_server_cfg.get("auto_restart"), bool):
1725
+ raise ConfigError("app_server.auto_restart must be boolean or null")
1405
1726
  for key in ("max_handles", "idle_ttl_seconds"):
1406
1727
  if key in app_server_cfg and app_server_cfg.get(key) is not None:
1407
1728
  if not isinstance(app_server_cfg.get(key), int):
@@ -1420,6 +1741,47 @@ def _validate_app_server_config(cfg: Dict[str, Any]) -> None:
1420
1741
  ):
1421
1742
  if not isinstance(app_server_cfg.get("request_timeout"), (int, float)):
1422
1743
  raise ConfigError("app_server.request_timeout must be a number or null")
1744
+ for key in (
1745
+ "turn_stall_timeout_seconds",
1746
+ "turn_stall_poll_interval_seconds",
1747
+ "turn_stall_recovery_min_interval_seconds",
1748
+ ):
1749
+ if key in app_server_cfg and app_server_cfg.get(key) is not None:
1750
+ if not isinstance(app_server_cfg.get(key), (int, float)):
1751
+ raise ConfigError(f"app_server.{key} must be a number or null")
1752
+ client_cfg = app_server_cfg.get("client")
1753
+ if client_cfg is not None:
1754
+ if not isinstance(client_cfg, dict):
1755
+ raise ConfigError("app_server.client must be a mapping if provided")
1756
+ for key in (
1757
+ "max_message_bytes",
1758
+ "oversize_preview_bytes",
1759
+ "max_oversize_drain_bytes",
1760
+ ):
1761
+ if key in client_cfg:
1762
+ value = client_cfg.get(key)
1763
+ if not isinstance(value, int):
1764
+ raise ConfigError(f"app_server.client.{key} must be an integer")
1765
+ if value <= 0:
1766
+ raise ConfigError(f"app_server.client.{key} must be > 0")
1767
+ for key in (
1768
+ "restart_backoff_initial_seconds",
1769
+ "restart_backoff_max_seconds",
1770
+ "restart_backoff_jitter_ratio",
1771
+ ):
1772
+ if key in client_cfg:
1773
+ value = client_cfg.get(key)
1774
+ if not isinstance(value, (int, float)):
1775
+ raise ConfigError(
1776
+ f"app_server.client.{key} must be a number if provided"
1777
+ )
1778
+ if key == "restart_backoff_jitter_ratio":
1779
+ if value < 0:
1780
+ raise ConfigError(
1781
+ "app_server.client.restart_backoff_jitter_ratio must be >= 0"
1782
+ )
1783
+ elif value <= 0:
1784
+ raise ConfigError(f"app_server.client.{key} must be > 0")
1423
1785
  prompts = app_server_cfg.get("prompts")
1424
1786
  if prompts is not None:
1425
1787
  if not isinstance(prompts, dict):
@@ -1463,6 +1825,35 @@ def _validate_app_server_config(cfg: Dict[str, Any]) -> None:
1463
1825
  )
1464
1826
 
1465
1827
 
1828
+ def _validate_opencode_config(cfg: Dict[str, Any]) -> None:
1829
+ opencode_cfg = cfg.get("opencode")
1830
+ if opencode_cfg is None:
1831
+ return
1832
+ if not isinstance(opencode_cfg, dict):
1833
+ raise ConfigError("opencode section must be a mapping if provided")
1834
+ if (
1835
+ "session_stall_timeout_seconds" in opencode_cfg
1836
+ and opencode_cfg.get("session_stall_timeout_seconds") is not None
1837
+ ):
1838
+ if not isinstance(
1839
+ opencode_cfg.get("session_stall_timeout_seconds"), (int, float)
1840
+ ):
1841
+ raise ConfigError(
1842
+ "opencode.session_stall_timeout_seconds must be a number or null"
1843
+ )
1844
+
1845
+
1846
+ def _validate_update_config(cfg: Dict[str, Any]) -> None:
1847
+ update_cfg = cfg.get("update")
1848
+ if update_cfg is None:
1849
+ return
1850
+ if not isinstance(update_cfg, dict):
1851
+ raise ConfigError("update section must be a mapping if provided")
1852
+ if "skip_checks" in update_cfg and update_cfg.get("skip_checks") is not None:
1853
+ if not isinstance(update_cfg.get("skip_checks"), bool):
1854
+ raise ConfigError("update.skip_checks must be boolean or null")
1855
+
1856
+
1466
1857
  def _validate_agents_config(cfg: Dict[str, Any]) -> None:
1467
1858
  agents_cfg = cfg.get("agents")
1468
1859
  if agents_cfg is None:
@@ -1491,18 +1882,15 @@ def _validate_repo_config(cfg: Dict[str, Any], *, root: Path) -> None:
1491
1882
  for key, value in docs.items():
1492
1883
  if not isinstance(value, str) or not value:
1493
1884
  raise ConfigError(f"docs.{key} must be a non-empty string path")
1494
- path = Path(value)
1495
- if path.is_absolute():
1496
- raise ConfigError(f"docs.{key} must be a relative path under repo root")
1497
- if ".." in path.parts:
1498
- raise ConfigError(f"docs.{key} must not contain '..' segments")
1499
1885
  try:
1500
- (root / path).resolve().relative_to(root)
1501
- except ValueError as exc:
1502
- raise ConfigError(
1503
- f"docs.{key} must resolve under repo root ({root})"
1504
- ) from exc
1505
- for key in ("todo", "progress", "opinions", "spec", "summary"):
1886
+ resolve_config_path(
1887
+ value,
1888
+ root,
1889
+ scope=f"docs.{key}",
1890
+ )
1891
+ except ConfigPathError as exc:
1892
+ raise ConfigError(str(exc)) from exc
1893
+ for key in ("active_context", "decisions", "spec"):
1506
1894
  if not isinstance(docs.get(key), str) or not docs[key]:
1507
1895
  raise ConfigError(f"docs.{key} must be a non-empty string path")
1508
1896
  _validate_agents_config(cfg)
@@ -1555,6 +1943,18 @@ def _validate_repo_config(cfg: Dict[str, Any], *, root: Path) -> None:
1555
1943
  val = runner.get(k)
1556
1944
  if val is not None and not isinstance(val, int):
1557
1945
  raise ConfigError(f"runner.{k} must be an integer or null")
1946
+ ticket_flow_cfg = cfg.get("ticket_flow")
1947
+ if ticket_flow_cfg is not None and not isinstance(ticket_flow_cfg, dict):
1948
+ raise ConfigError("ticket_flow section must be a mapping if provided")
1949
+ if isinstance(ticket_flow_cfg, dict):
1950
+ if "approval_mode" in ticket_flow_cfg and not isinstance(
1951
+ ticket_flow_cfg.get("approval_mode"), str
1952
+ ):
1953
+ raise ConfigError("ticket_flow.approval_mode must be a string")
1954
+ if "default_approval_decision" in ticket_flow_cfg and not isinstance(
1955
+ ticket_flow_cfg.get("default_approval_decision"), str
1956
+ ):
1957
+ raise ConfigError("ticket_flow.default_approval_decision must be a string")
1558
1958
  git = cfg.get("git")
1559
1959
  if not isinstance(git, dict):
1560
1960
  raise ConfigError("git section must be a mapping")
@@ -1578,69 +1978,7 @@ def _validate_repo_config(cfg: Dict[str, Any], *, root: Path) -> None:
1578
1978
  github.get("sync_agent_timeout_seconds"), int
1579
1979
  ):
1580
1980
  raise ConfigError("github.sync_agent_timeout_seconds must be an integer")
1581
- pr_flow = github.get("pr_flow")
1582
- if pr_flow is not None:
1583
- if not isinstance(pr_flow, dict):
1584
- raise ConfigError("github.pr_flow must be a mapping if provided")
1585
- if "enabled" in pr_flow and not isinstance(pr_flow.get("enabled"), bool):
1586
- raise ConfigError("github.pr_flow.enabled must be boolean")
1587
- if "max_cycles" in pr_flow and not isinstance(
1588
- pr_flow.get("max_cycles"), int
1589
- ):
1590
- raise ConfigError("github.pr_flow.max_cycles must be an integer")
1591
- if "stop_condition" in pr_flow and not isinstance(
1592
- pr_flow.get("stop_condition"), str
1593
- ):
1594
- raise ConfigError("github.pr_flow.stop_condition must be a string")
1595
- for key in ("max_implementation_runs", "max_wallclock_seconds"):
1596
- val = pr_flow.get(key)
1597
- if val is not None and not isinstance(val, int):
1598
- raise ConfigError(
1599
- f"github.pr_flow.{key} must be an integer or null"
1600
- )
1601
- review_cfg = pr_flow.get("review")
1602
- if review_cfg is not None:
1603
- if not isinstance(review_cfg, dict):
1604
- raise ConfigError("github.pr_flow.review must be a mapping")
1605
- for key in ("include_codex", "include_github", "include_checks"):
1606
- if key in review_cfg and not isinstance(review_cfg.get(key), bool):
1607
- raise ConfigError(
1608
- f"github.pr_flow.review.{key} must be boolean"
1609
- )
1610
- chatops_cfg = pr_flow.get("chatops")
1611
- if chatops_cfg is not None:
1612
- if not isinstance(chatops_cfg, dict):
1613
- raise ConfigError("github.pr_flow.chatops must be a mapping")
1614
- if "enabled" in chatops_cfg and not isinstance(
1615
- chatops_cfg.get("enabled"), bool
1616
- ):
1617
- raise ConfigError("github.pr_flow.chatops.enabled must be boolean")
1618
- if "poll_interval_seconds" in chatops_cfg and not isinstance(
1619
- chatops_cfg.get("poll_interval_seconds"), int
1620
- ):
1621
- raise ConfigError(
1622
- "github.pr_flow.chatops.poll_interval_seconds must be an integer"
1623
- )
1624
- for key in ("allow_users", "allow_associations"):
1625
- if key in chatops_cfg and not isinstance(
1626
- chatops_cfg.get(key), list
1627
- ):
1628
- raise ConfigError(
1629
- f"github.pr_flow.chatops.{key} must be a list"
1630
- )
1631
- if "ignore_bots" in chatops_cfg and not isinstance(
1632
- chatops_cfg.get("ignore_bots"), bool
1633
- ):
1634
- raise ConfigError(
1635
- "github.pr_flow.chatops.ignore_bots must be boolean"
1636
- )
1637
- if chatops_cfg.get("enabled", False):
1638
- allow_users = chatops_cfg.get("allow_users") or []
1639
- allow_assoc = chatops_cfg.get("allow_associations") or []
1640
- if not allow_users and not allow_assoc:
1641
- raise ConfigError(
1642
- "github.pr_flow.chatops.enabled requires at least one of allow_users or allow_associations to be non-empty"
1643
- )
1981
+
1644
1982
  server = cfg.get("server")
1645
1983
  if not isinstance(server, dict):
1646
1984
  raise ConfigError("server section must be a mapping")
@@ -1658,6 +1996,8 @@ def _validate_repo_config(cfg: Dict[str, Any], *, root: Path) -> None:
1658
1996
  raise ConfigError("server.auth_token_env must be a string if provided")
1659
1997
  _validate_server_security(server)
1660
1998
  _validate_app_server_config(cfg)
1999
+ _validate_opencode_config(cfg)
2000
+ _validate_update_config(cfg)
1661
2001
  notifications_cfg = cfg.get("notifications")
1662
2002
  if notifications_cfg is not None:
1663
2003
  if not isinstance(notifications_cfg, dict):
@@ -1760,9 +2100,13 @@ def _validate_repo_config(cfg: Dict[str, Any], *, root: Path) -> None:
1760
2100
  log_cfg = cfg.get("log")
1761
2101
  if not isinstance(log_cfg, dict):
1762
2102
  raise ConfigError("log section must be a mapping")
1763
- for key in ("path",):
1764
- if not isinstance(log_cfg.get(key, ""), str):
1765
- raise ConfigError(f"log.{key} must be a string path")
2103
+ if "path" in log_cfg:
2104
+ if not isinstance(log_cfg["path"], str):
2105
+ raise ConfigError("log.path must be a string path")
2106
+ try:
2107
+ resolve_config_path(log_cfg["path"], root, scope="log.path")
2108
+ except ConfigPathError as exc:
2109
+ raise ConfigError(str(exc)) from exc
1766
2110
  for key in ("max_bytes", "backup_count"):
1767
2111
  if not isinstance(log_cfg.get(key, 0), int):
1768
2112
  raise ConfigError(f"log.{key} must be an integer")
@@ -1770,10 +2114,15 @@ def _validate_repo_config(cfg: Dict[str, Any], *, root: Path) -> None:
1770
2114
  if server_log_cfg is not None and not isinstance(server_log_cfg, dict):
1771
2115
  raise ConfigError("server_log section must be a mapping or null")
1772
2116
  if isinstance(server_log_cfg, dict):
1773
- if "path" in server_log_cfg and not isinstance(
1774
- server_log_cfg.get("path", ""), str
1775
- ):
1776
- raise ConfigError("server_log.path must be a string path")
2117
+ if "path" in server_log_cfg:
2118
+ if not isinstance(server_log_cfg["path"], str):
2119
+ raise ConfigError("server_log.path must be a string path")
2120
+ try:
2121
+ resolve_config_path(
2122
+ server_log_cfg["path"], root, scope="server_log.path"
2123
+ )
2124
+ except ConfigPathError as exc:
2125
+ raise ConfigError(str(exc)) from exc
1777
2126
  for key in ("max_bytes", "backup_count"):
1778
2127
  if key in server_log_cfg and not isinstance(server_log_cfg.get(key), int):
1779
2128
  raise ConfigError(f"server_log.{key} must be an integer")
@@ -1792,6 +2141,8 @@ def _validate_hub_config(cfg: Dict[str, Any]) -> None:
1792
2141
  if "repo" in cfg:
1793
2142
  raise ConfigError("repo section is no longer supported; use repo_defaults")
1794
2143
  _validate_agents_config(cfg)
2144
+ _validate_opencode_config(cfg)
2145
+ _validate_update_config(cfg)
1795
2146
  repo_defaults = cfg.get("repo_defaults")
1796
2147
  if repo_defaults is not None:
1797
2148
  if not isinstance(repo_defaults, dict):
@@ -1918,6 +2269,21 @@ def _validate_housekeeping_config(cfg: Dict[str, Any]) -> None:
1918
2269
  )
1919
2270
  if "path" in rule and not isinstance(rule.get("path"), str):
1920
2271
  raise ConfigError(f"housekeeping.rules[{idx}].path must be a string")
2272
+ if "path" in rule:
2273
+ path_value = rule.get("path")
2274
+ if not isinstance(path_value, str) or not path_value:
2275
+ raise ConfigError(
2276
+ f"housekeeping.rules[{idx}].path must be a non-empty string path"
2277
+ )
2278
+ path = Path(path_value)
2279
+ if path.is_absolute():
2280
+ raise ConfigError(
2281
+ f"housekeeping.rules[{idx}].path must be relative or start with '~'"
2282
+ )
2283
+ if ".." in path.parts:
2284
+ raise ConfigError(
2285
+ f"housekeeping.rules[{idx}].path must not contain '..' segments"
2286
+ )
1921
2287
  if "glob" in rule and not isinstance(rule.get("glob"), str):
1922
2288
  raise ConfigError(
1923
2289
  f"housekeeping.rules[{idx}].glob must be a string if provided"
@@ -2075,6 +2441,29 @@ def _validate_telegram_bot_config(cfg: Dict[str, Any]) -> None:
2075
2441
  raise ConfigError(f"telegram_bot.shell.{key} must be an integer")
2076
2442
  if isinstance(value, int) and value <= 0:
2077
2443
  raise ConfigError(f"telegram_bot.shell.{key} must be greater than 0")
2444
+ cache_cfg = telegram_cfg.get("cache")
2445
+ if cache_cfg is not None and not isinstance(cache_cfg, dict):
2446
+ raise ConfigError("telegram_bot.cache must be a mapping if provided")
2447
+ if isinstance(cache_cfg, dict):
2448
+ for key in (
2449
+ "cleanup_interval_seconds",
2450
+ "coalesce_buffer_ttl_seconds",
2451
+ "media_batch_buffer_ttl_seconds",
2452
+ "model_pending_ttl_seconds",
2453
+ "pending_approval_ttl_seconds",
2454
+ "pending_question_ttl_seconds",
2455
+ "reasoning_buffer_ttl_seconds",
2456
+ "selection_state_ttl_seconds",
2457
+ "turn_preview_ttl_seconds",
2458
+ "progress_stream_ttl_seconds",
2459
+ "oversize_warning_ttl_seconds",
2460
+ "update_id_persist_interval_seconds",
2461
+ ):
2462
+ value = cache_cfg.get(key)
2463
+ if value is not None and not isinstance(value, (int, float)):
2464
+ raise ConfigError(f"telegram_bot.cache.{key} must be a number")
2465
+ if isinstance(value, (int, float)) and value <= 0:
2466
+ raise ConfigError(f"telegram_bot.cache.{key} must be > 0")
2078
2467
  command_reg_cfg = telegram_cfg.get("command_registration")
2079
2468
  if command_reg_cfg is not None and not isinstance(command_reg_cfg, dict):
2080
2469
  raise ConfigError("telegram_bot.command_registration must be a mapping")
@@ -2112,14 +2501,47 @@ def _validate_telegram_bot_config(cfg: Dict[str, Any]) -> None:
2112
2501
  raise ConfigError(
2113
2502
  "telegram_bot.command_registration.scopes.language_code must be a string or null"
2114
2503
  )
2504
+ if "trigger_mode" in telegram_cfg and not isinstance(
2505
+ telegram_cfg.get("trigger_mode"), str
2506
+ ):
2507
+ raise ConfigError("telegram_bot.trigger_mode must be a string")
2115
2508
  if "state_file" in telegram_cfg and not isinstance(
2116
2509
  telegram_cfg.get("state_file"), str
2117
2510
  ):
2118
2511
  raise ConfigError("telegram_bot.state_file must be a string path")
2512
+ if (
2513
+ "opencode_command" in telegram_cfg
2514
+ and not isinstance(telegram_cfg.get("opencode_command"), (list, str))
2515
+ and telegram_cfg.get("opencode_command") is not None
2516
+ ):
2517
+ raise ConfigError("telegram_bot.opencode_command must be a list or string")
2119
2518
  if "app_server_command" in telegram_cfg and not isinstance(
2120
2519
  telegram_cfg.get("app_server_command"), (list, str)
2121
2520
  ):
2122
2521
  raise ConfigError("telegram_bot.app_server_command must be a list or string")
2522
+ app_server_cfg = telegram_cfg.get("app_server")
2523
+ if app_server_cfg is not None and not isinstance(app_server_cfg, dict):
2524
+ raise ConfigError("telegram_bot.app_server must be a mapping if provided")
2525
+ if isinstance(app_server_cfg, dict):
2526
+ if (
2527
+ "turn_timeout_seconds" in app_server_cfg
2528
+ and app_server_cfg.get("turn_timeout_seconds") is not None
2529
+ and not isinstance(app_server_cfg.get("turn_timeout_seconds"), (int, float))
2530
+ ):
2531
+ raise ConfigError(
2532
+ "telegram_bot.app_server.turn_timeout_seconds must be a number or null"
2533
+ )
2534
+ agent_timeouts_cfg = telegram_cfg.get("agent_timeouts")
2535
+ if agent_timeouts_cfg is not None and not isinstance(agent_timeouts_cfg, dict):
2536
+ raise ConfigError("telegram_bot.agent_timeouts must be a mapping if provided")
2537
+ if isinstance(agent_timeouts_cfg, dict):
2538
+ for _key, value in agent_timeouts_cfg.items():
2539
+ if value is None:
2540
+ continue
2541
+ if not isinstance(value, (int, float)):
2542
+ raise ConfigError(
2543
+ "telegram_bot.agent_timeouts values must be numbers or null"
2544
+ )
2123
2545
  polling_cfg = telegram_cfg.get("polling")
2124
2546
  if polling_cfg is not None and not isinstance(polling_cfg, dict):
2125
2547
  raise ConfigError("telegram_bot.polling must be a mapping if provided")