codex-autorunner 0.1.2__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 (189) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/opencode/client.py +68 -35
  3. codex_autorunner/agents/opencode/logging.py +21 -5
  4. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  5. codex_autorunner/agents/opencode/runtime.py +118 -30
  6. codex_autorunner/agents/opencode/supervisor.py +36 -48
  7. codex_autorunner/agents/registry.py +136 -8
  8. codex_autorunner/api.py +25 -0
  9. codex_autorunner/bootstrap.py +16 -35
  10. codex_autorunner/cli.py +157 -139
  11. codex_autorunner/core/about_car.py +44 -32
  12. codex_autorunner/core/adapter_utils.py +21 -0
  13. codex_autorunner/core/app_server_logging.py +7 -3
  14. codex_autorunner/core/app_server_prompts.py +27 -260
  15. codex_autorunner/core/app_server_threads.py +15 -26
  16. codex_autorunner/core/codex_runner.py +6 -0
  17. codex_autorunner/core/config.py +390 -100
  18. codex_autorunner/core/docs.py +10 -2
  19. codex_autorunner/core/drafts.py +82 -0
  20. codex_autorunner/core/engine.py +278 -262
  21. codex_autorunner/core/flows/__init__.py +25 -0
  22. codex_autorunner/core/flows/controller.py +178 -0
  23. codex_autorunner/core/flows/definition.py +82 -0
  24. codex_autorunner/core/flows/models.py +75 -0
  25. codex_autorunner/core/flows/runtime.py +351 -0
  26. codex_autorunner/core/flows/store.py +485 -0
  27. codex_autorunner/core/flows/transition.py +133 -0
  28. codex_autorunner/core/flows/worker_process.py +242 -0
  29. codex_autorunner/core/hub.py +15 -9
  30. codex_autorunner/core/locks.py +4 -0
  31. codex_autorunner/core/prompt.py +15 -7
  32. codex_autorunner/core/redaction.py +29 -0
  33. codex_autorunner/core/review_context.py +5 -8
  34. codex_autorunner/core/run_index.py +6 -0
  35. codex_autorunner/core/runner_process.py +5 -2
  36. codex_autorunner/core/state.py +0 -88
  37. codex_autorunner/core/static_assets.py +55 -0
  38. codex_autorunner/core/supervisor_utils.py +67 -0
  39. codex_autorunner/core/update.py +20 -11
  40. codex_autorunner/core/update_runner.py +2 -0
  41. codex_autorunner/core/utils.py +29 -2
  42. codex_autorunner/discovery.py +2 -4
  43. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  44. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  45. codex_autorunner/integrations/agents/__init__.py +27 -0
  46. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  47. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  48. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  49. codex_autorunner/integrations/agents/run_event.py +71 -0
  50. codex_autorunner/integrations/app_server/client.py +576 -92
  51. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  52. codex_autorunner/integrations/telegram/adapter.py +141 -167
  53. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  54. codex_autorunner/integrations/telegram/config.py +175 -0
  55. codex_autorunner/integrations/telegram/constants.py +16 -1
  56. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  57. codex_autorunner/integrations/telegram/doctor.py +47 -0
  58. codex_autorunner/integrations/telegram/handlers/callbacks.py +0 -4
  59. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  64. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  65. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  66. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +133 -475
  67. codex_autorunner/integrations/telegram/handlers/commands_spec.py +11 -4
  68. codex_autorunner/integrations/telegram/handlers/messages.py +120 -9
  69. codex_autorunner/integrations/telegram/helpers.py +88 -16
  70. codex_autorunner/integrations/telegram/outbox.py +208 -37
  71. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  72. codex_autorunner/integrations/telegram/service.py +214 -40
  73. codex_autorunner/integrations/telegram/state.py +100 -2
  74. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  75. codex_autorunner/integrations/telegram/transport.py +36 -3
  76. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  77. codex_autorunner/manifest.py +2 -0
  78. codex_autorunner/plugin_api.py +22 -0
  79. codex_autorunner/routes/__init__.py +23 -14
  80. codex_autorunner/routes/analytics.py +239 -0
  81. codex_autorunner/routes/base.py +81 -109
  82. codex_autorunner/routes/file_chat.py +836 -0
  83. codex_autorunner/routes/flows.py +980 -0
  84. codex_autorunner/routes/messages.py +459 -0
  85. codex_autorunner/routes/system.py +6 -1
  86. codex_autorunner/routes/usage.py +87 -0
  87. codex_autorunner/routes/workspace.py +271 -0
  88. codex_autorunner/server.py +2 -1
  89. codex_autorunner/static/agentControls.js +1 -0
  90. codex_autorunner/static/agentEvents.js +248 -0
  91. codex_autorunner/static/app.js +25 -22
  92. codex_autorunner/static/autoRefresh.js +29 -1
  93. codex_autorunner/static/bootstrap.js +1 -0
  94. codex_autorunner/static/bus.js +1 -0
  95. codex_autorunner/static/cache.js +1 -0
  96. codex_autorunner/static/constants.js +20 -4
  97. codex_autorunner/static/dashboard.js +162 -196
  98. codex_autorunner/static/diffRenderer.js +37 -0
  99. codex_autorunner/static/docChatCore.js +324 -0
  100. codex_autorunner/static/docChatStorage.js +65 -0
  101. codex_autorunner/static/docChatVoice.js +65 -0
  102. codex_autorunner/static/docEditor.js +133 -0
  103. codex_autorunner/static/env.js +1 -0
  104. codex_autorunner/static/eventSummarizer.js +166 -0
  105. codex_autorunner/static/fileChat.js +182 -0
  106. codex_autorunner/static/health.js +155 -0
  107. codex_autorunner/static/hub.js +41 -118
  108. codex_autorunner/static/index.html +787 -858
  109. codex_autorunner/static/liveUpdates.js +1 -0
  110. codex_autorunner/static/loader.js +1 -0
  111. codex_autorunner/static/messages.js +470 -0
  112. codex_autorunner/static/mobileCompact.js +2 -1
  113. codex_autorunner/static/settings.js +24 -211
  114. codex_autorunner/static/styles.css +7567 -3865
  115. codex_autorunner/static/tabs.js +28 -5
  116. codex_autorunner/static/terminal.js +14 -0
  117. codex_autorunner/static/terminalManager.js +34 -59
  118. codex_autorunner/static/ticketChatActions.js +333 -0
  119. codex_autorunner/static/ticketChatEvents.js +16 -0
  120. codex_autorunner/static/ticketChatStorage.js +16 -0
  121. codex_autorunner/static/ticketChatStream.js +264 -0
  122. codex_autorunner/static/ticketEditor.js +750 -0
  123. codex_autorunner/static/ticketVoice.js +9 -0
  124. codex_autorunner/static/tickets.js +1315 -0
  125. codex_autorunner/static/utils.js +32 -3
  126. codex_autorunner/static/voice.js +1 -0
  127. codex_autorunner/static/workspace.js +672 -0
  128. codex_autorunner/static/workspaceApi.js +53 -0
  129. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  130. codex_autorunner/tickets/__init__.py +20 -0
  131. codex_autorunner/tickets/agent_pool.py +377 -0
  132. codex_autorunner/tickets/files.py +85 -0
  133. codex_autorunner/tickets/frontmatter.py +55 -0
  134. codex_autorunner/tickets/lint.py +102 -0
  135. codex_autorunner/tickets/models.py +95 -0
  136. codex_autorunner/tickets/outbox.py +232 -0
  137. codex_autorunner/tickets/replies.py +179 -0
  138. codex_autorunner/tickets/runner.py +823 -0
  139. codex_autorunner/tickets/spec_ingest.py +77 -0
  140. codex_autorunner/web/app.py +269 -91
  141. codex_autorunner/web/middleware.py +3 -4
  142. codex_autorunner/web/schemas.py +89 -109
  143. codex_autorunner/web/static_assets.py +1 -44
  144. codex_autorunner/workspace/__init__.py +40 -0
  145. codex_autorunner/workspace/paths.py +319 -0
  146. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +18 -21
  147. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  148. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  149. codex_autorunner/agents/execution/policy.py +0 -292
  150. codex_autorunner/agents/factory.py +0 -52
  151. codex_autorunner/agents/orchestrator.py +0 -358
  152. codex_autorunner/core/doc_chat.py +0 -1446
  153. codex_autorunner/core/snapshot.py +0 -580
  154. codex_autorunner/integrations/github/chatops.py +0 -268
  155. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  156. codex_autorunner/routes/docs.py +0 -381
  157. codex_autorunner/routes/github.py +0 -327
  158. codex_autorunner/routes/runs.py +0 -250
  159. codex_autorunner/spec_ingest.py +0 -812
  160. codex_autorunner/static/docChatActions.js +0 -287
  161. codex_autorunner/static/docChatEvents.js +0 -300
  162. codex_autorunner/static/docChatRender.js +0 -205
  163. codex_autorunner/static/docChatStream.js +0 -361
  164. codex_autorunner/static/docs.js +0 -20
  165. codex_autorunner/static/docsClipboard.js +0 -69
  166. codex_autorunner/static/docsCrud.js +0 -257
  167. codex_autorunner/static/docsDocUpdates.js +0 -62
  168. codex_autorunner/static/docsDrafts.js +0 -16
  169. codex_autorunner/static/docsElements.js +0 -69
  170. codex_autorunner/static/docsInit.js +0 -285
  171. codex_autorunner/static/docsParse.js +0 -160
  172. codex_autorunner/static/docsSnapshot.js +0 -87
  173. codex_autorunner/static/docsSpecIngest.js +0 -263
  174. codex_autorunner/static/docsState.js +0 -127
  175. codex_autorunner/static/docsThreadRegistry.js +0 -44
  176. codex_autorunner/static/docsUi.js +0 -153
  177. codex_autorunner/static/docsVoice.js +0 -56
  178. codex_autorunner/static/github.js +0 -504
  179. codex_autorunner/static/logs.js +0 -678
  180. codex_autorunner/static/review.js +0 -157
  181. codex_autorunner/static/runs.js +0 -418
  182. codex_autorunner/static/snapshot.js +0 -124
  183. codex_autorunner/static/state.js +0 -94
  184. codex_autorunner/static/todoPreview.js +0 -27
  185. codex_autorunner/workspace.py +0 -16
  186. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  187. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  188. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  189. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -52,13 +52,9 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
52
52
  "version": CONFIG_VERSION,
53
53
  "mode": "repo",
54
54
  "docs": {
55
- "todo": ".codex-autorunner/TODO.md",
56
- "progress": ".codex-autorunner/PROGRESS.md",
57
- "opinions": ".codex-autorunner/OPINIONS.md",
58
- "spec": ".codex-autorunner/SPEC.md",
59
- "summary": ".codex-autorunner/SUMMARY.md",
60
- "snapshot": ".codex-autorunner/SNAPSHOT.md",
61
- "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",
62
58
  },
63
59
  "review": {
64
60
  "enabled": True,
@@ -99,6 +95,9 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
99
95
  "prev_run_max_chars": 6000,
100
96
  "template": ".codex-autorunner/prompt.txt",
101
97
  },
98
+ "security": {
99
+ "redact_run_logs": True,
100
+ },
102
101
  "runner": {
103
102
  "sleep_seconds": 5,
104
103
  "stop_after_runs": None,
@@ -118,8 +117,8 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
118
117
  "reasoning": None,
119
118
  "max_wallclock_seconds": None,
120
119
  "context": {
121
- "primary_docs": ["spec", "progress"],
122
- "include_docs": ["todo", "summary"],
120
+ "primary_docs": ["spec", "decisions"],
121
+ "include_docs": ["active_context"],
123
122
  "include_last_run_artifacts": True,
124
123
  "max_doc_chars": 20000,
125
124
  },
@@ -129,6 +128,11 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
129
128
  },
130
129
  },
131
130
  },
131
+ "ticket_flow": {
132
+ "approval_mode": "yolo",
133
+ # Keep ticket_flow deterministic by default; surfaces can tighten this.
134
+ "default_approval_decision": "accept",
135
+ },
132
136
  "git": {
133
137
  "auto_commit": False,
134
138
  "commit_message_template": "[codex] run #{run_id}",
@@ -139,34 +143,33 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
139
143
  "sync_commit_mode": "auto", # none|auto|always
140
144
  # Bounds the agentic sync step in GitHubService.sync_pr (seconds).
141
145
  "sync_agent_timeout_seconds": 1800,
142
- "pr_flow": {
143
- "enabled": True,
144
- "max_cycles": 3,
145
- "stop_condition": "no_issues",
146
- "max_implementation_runs": None,
147
- "max_wallclock_seconds": None,
148
- "review": {
149
- "include_codex": True,
150
- "include_github": True,
151
- "include_checks": True,
152
- },
153
- "chatops": {
154
- "enabled": False,
155
- "poll_interval_seconds": 60,
156
- "allow_users": [],
157
- "allow_associations": [],
158
- "ignore_bots": True,
159
- },
160
- },
146
+ },
147
+ "update": {
148
+ "skip_checks": False,
161
149
  },
162
150
  "app_server": {
163
151
  "command": ["codex", "app-server"],
164
152
  "state_root": "~/.codex-autorunner/workspaces",
153
+ "auto_restart": True,
165
154
  "max_handles": 20,
166
155
  "idle_ttl_seconds": 3600,
167
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,
168
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
+ },
169
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.
170
173
  "doc_chat": {
171
174
  "max_chars": 12000,
172
175
  "message_max_chars": 2000,
@@ -186,6 +189,9 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
186
189
  },
187
190
  },
188
191
  },
192
+ "opencode": {
193
+ "session_stall_timeout_seconds": 60,
194
+ },
189
195
  "server": {
190
196
  "host": "127.0.0.1",
191
197
  "port": 4173,
@@ -249,6 +255,20 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
249
255
  "timeout_ms": 120000,
250
256
  "max_output_chars": 3800,
251
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
+ },
252
272
  "command_registration": {
253
273
  "enabled": True,
254
274
  "scopes": [
@@ -256,6 +276,7 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
256
276
  {"type": "all_group_chats", "language_code": ""},
257
277
  ],
258
278
  },
279
+ "opencode_command": None,
259
280
  "state_file": ".codex-autorunner/telegram_state.sqlite3",
260
281
  "app_server_command_env": "CAR_TELEGRAM_APP_SERVER_COMMAND",
261
282
  "app_server_command": ["codex", "app-server"],
@@ -264,6 +285,10 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
264
285
  "idle_ttl_seconds": 3600,
265
286
  "turn_timeout_seconds": 28800,
266
287
  },
288
+ "agent_timeouts": {
289
+ "codex": 28800,
290
+ "opencode": 28800,
291
+ },
267
292
  "polling": {
268
293
  "timeout_seconds": 30,
269
294
  "allowed_updates": ["message", "edited_message", "callback_query"],
@@ -395,13 +420,16 @@ REPO_DEFAULT_KEYS = {
395
420
  "codex",
396
421
  "prompt",
397
422
  "runner",
423
+ "ticket_flow",
398
424
  "git",
399
425
  "github",
426
+ "update",
400
427
  "notifications",
401
428
  "voice",
402
429
  "log",
403
430
  "server_log",
404
431
  "review",
432
+ "opencode",
405
433
  }
406
434
  DEFAULT_REPO_DEFAULTS = {
407
435
  key: json.loads(json.dumps(DEFAULT_REPO_CONFIG[key])) for key in REPO_DEFAULT_KEYS
@@ -410,10 +438,12 @@ REPO_SHARED_KEYS = {
410
438
  "agents",
411
439
  "server",
412
440
  "app_server",
441
+ "opencode",
413
442
  "telegram_bot",
414
443
  "terminal",
415
444
  "static_assets",
416
445
  "housekeeping",
446
+ "update",
417
447
  }
418
448
 
419
449
  DEFAULT_HUB_CONFIG: Dict[str, Any] = {
@@ -476,6 +506,20 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
476
506
  "timeout_ms": 120000,
477
507
  "max_output_chars": 3800,
478
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
+ },
479
523
  "command_registration": {
480
524
  "enabled": True,
481
525
  "scopes": [
@@ -483,6 +527,7 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
483
527
  {"type": "all_group_chats", "language_code": ""},
484
528
  ],
485
529
  },
530
+ "opencode_command": None,
486
531
  "state_file": ".codex-autorunner/telegram_state.sqlite3",
487
532
  "app_server_command_env": "CAR_TELEGRAM_APP_SERVER_COMMAND",
488
533
  "app_server_command": ["codex", "app-server"],
@@ -513,13 +558,28 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
513
558
  "backup_count": 3,
514
559
  },
515
560
  },
561
+ "update": {
562
+ "skip_checks": False,
563
+ },
516
564
  "app_server": {
517
565
  "command": ["codex", "app-server"],
518
566
  "state_root": "~/.codex-autorunner/workspaces",
567
+ "auto_restart": True,
519
568
  "max_handles": 20,
520
569
  "idle_ttl_seconds": 3600,
521
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,
522
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
+ },
523
583
  "prompts": {
524
584
  "doc_chat": {
525
585
  "max_chars": 12000,
@@ -540,6 +600,9 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
540
600
  },
541
601
  },
542
602
  },
603
+ "opencode": {
604
+ "session_stall_timeout_seconds": 60,
605
+ },
543
606
  "server": {
544
607
  "host": "127.0.0.1",
545
608
  "port": 4173,
@@ -697,17 +760,37 @@ class AppServerPromptsConfig:
697
760
  autorunner: AppServerAutorunnerPromptConfig
698
761
 
699
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
+
700
773
  @dataclasses.dataclass
701
774
  class AppServerConfig:
702
775
  command: List[str]
703
776
  state_root: Path
777
+ auto_restart: Optional[bool]
704
778
  max_handles: Optional[int]
705
779
  idle_ttl_seconds: Optional[int]
706
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]
707
784
  request_timeout: Optional[float]
785
+ client: AppServerClientConfig
708
786
  prompts: AppServerPromptsConfig
709
787
 
710
788
 
789
+ @dataclasses.dataclass
790
+ class OpenCodeConfig:
791
+ session_stall_timeout_seconds: Optional[float]
792
+
793
+
711
794
  @dataclasses.dataclass(frozen=True)
712
795
  class AgentConfig:
713
796
  binary: str
@@ -722,6 +805,7 @@ class RepoConfig:
722
805
  root: Path
723
806
  version: int
724
807
  mode: str
808
+ security: Dict[str, Any]
725
809
  docs: Dict[str, Path]
726
810
  codex_binary: str
727
811
  codex_args: List[str]
@@ -735,9 +819,12 @@ class RepoConfig:
735
819
  runner_stop_after_runs: Optional[int]
736
820
  runner_max_wallclock_seconds: Optional[int]
737
821
  runner_no_progress_threshold: int
822
+ ticket_flow: Dict[str, Any]
738
823
  git_auto_commit: bool
739
824
  git_commit_message_template: str
825
+ update_skip_checks: bool
740
826
  app_server: AppServerConfig
827
+ opencode: OpenCodeConfig
741
828
  server_host: str
742
829
  server_port: int
743
830
  server_base_path: str
@@ -785,7 +872,9 @@ class HubConfig:
785
872
  repo_server_inherit: bool
786
873
  update_repo_url: str
787
874
  update_repo_ref: str
875
+ update_skip_checks: bool
788
876
  app_server: AppServerConfig
877
+ opencode: OpenCodeConfig
789
878
  server_host: str
790
879
  server_port: int
791
880
  server_base_path: str
@@ -1024,6 +1113,11 @@ def _parse_app_server_config(
1024
1113
  allow_home=True,
1025
1114
  scope="app_server.state_root",
1026
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)
1027
1121
  max_handles_raw = cfg.get("max_handles", defaults.get("max_handles"))
1028
1122
  max_handles = int(max_handles_raw) if max_handles_raw is not None else None
1029
1123
  if max_handles is not None and max_handles <= 0:
@@ -1040,25 +1134,115 @@ def _parse_app_server_config(
1040
1134
  )
1041
1135
  if turn_timeout_seconds is not None and turn_timeout_seconds <= 0:
1042
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
+ )
1043
1173
  request_timeout_raw = cfg.get("request_timeout", defaults.get("request_timeout"))
1044
1174
  request_timeout = (
1045
1175
  float(request_timeout_raw) if request_timeout_raw is not None else None
1046
1176
  )
1047
1177
  if request_timeout is not None and request_timeout <= 0:
1048
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
+
1049
1198
  prompt_defaults = defaults.get("prompts")
1050
1199
  prompts = _parse_app_server_prompts_config(cfg.get("prompts"), prompt_defaults)
1051
1200
  return AppServerConfig(
1052
1201
  command=command,
1053
1202
  state_root=state_root,
1203
+ auto_restart=auto_restart,
1054
1204
  max_handles=max_handles,
1055
1205
  idle_ttl_seconds=idle_ttl_seconds,
1056
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,
1057
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
+ ),
1058
1223
  prompts=prompts,
1059
1224
  )
1060
1225
 
1061
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
+
1062
1246
  def _parse_agents_config(
1063
1247
  cfg: Optional[Dict[str, Any]], defaults: Dict[str, Any]
1064
1248
  ) -> Dict[str, AgentConfig]:
@@ -1207,6 +1391,21 @@ def load_hub_config_data(config_path: Path) -> Dict[str, Any]:
1207
1391
 
1208
1392
  def _resolve_hub_config_path(start: Path) -> Path:
1209
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
+
1210
1409
  if not config_path:
1211
1410
  raise ConfigError(
1212
1411
  f"Missing hub config file; expected to find {CONFIG_FILENAME} in {start} or parents (use --hub to specify)"
@@ -1272,15 +1471,9 @@ def load_repo_config(start: Path, hub_path: Optional[Path] = None) -> RepoConfig
1272
1471
  def _build_repo_config(config_path: Path, cfg: Dict[str, Any]) -> RepoConfig:
1273
1472
  root = config_path.parent.parent.resolve()
1274
1473
  docs = {
1275
- "todo": Path(cfg["docs"]["todo"]),
1276
- "progress": Path(cfg["docs"]["progress"]),
1277
- "opinions": Path(cfg["docs"]["opinions"]),
1474
+ "active_context": Path(cfg["docs"]["active_context"]),
1475
+ "decisions": Path(cfg["docs"]["decisions"]),
1278
1476
  "spec": Path(cfg["docs"]["spec"]),
1279
- "summary": Path(cfg["docs"]["summary"]),
1280
- "snapshot": Path(cfg["docs"].get("snapshot", ".codex-autorunner/SNAPSHOT.md")),
1281
- "snapshot_state": Path(
1282
- cfg["docs"].get("snapshot_state", ".codex-autorunner/snapshot_state.json")
1283
- ),
1284
1477
  }
1285
1478
  voice_cfg = cfg.get("voice") if isinstance(cfg.get("voice"), dict) else {}
1286
1479
  voice_cfg = cast(Dict[str, Any], voice_cfg)
@@ -1301,12 +1494,19 @@ def _build_repo_config(config_path: Path, cfg: Dict[str, Any]) -> RepoConfig:
1301
1494
  cfg.get("notifications") if isinstance(cfg.get("notifications"), dict) else {}
1302
1495
  )
1303
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)
1304
1499
  log_cfg = cfg.get("log", {})
1305
1500
  log_cfg = cast(Dict[str, Any], log_cfg if isinstance(log_cfg, dict) else {})
1306
1501
  server_log_cfg = cfg.get("server_log", {}) or {}
1307
1502
  server_log_cfg = cast(
1308
1503
  Dict[str, Any], server_log_cfg if isinstance(server_log_cfg, dict) else {}
1309
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))
1310
1510
  return RepoConfig(
1311
1511
  raw=cfg,
1312
1512
  root=root,
@@ -1327,11 +1527,17 @@ def _build_repo_config(config_path: Path, cfg: Dict[str, Any]) -> RepoConfig:
1327
1527
  runner_no_progress_threshold=int(cfg["runner"].get("no_progress_threshold", 3)),
1328
1528
  git_auto_commit=bool(cfg["git"].get("auto_commit", False)),
1329
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 {}),
1330
1532
  app_server=_parse_app_server_config(
1331
1533
  cfg.get("app_server"),
1332
1534
  root,
1333
1535
  DEFAULT_REPO_CONFIG["app_server"],
1334
1536
  ),
1537
+ opencode=_parse_opencode_config(
1538
+ cfg.get("opencode"), root, DEFAULT_REPO_CONFIG.get("opencode")
1539
+ ),
1540
+ security=security_cfg,
1335
1541
  server_host=str(cfg["server"].get("host")),
1336
1542
  server_port=int(cfg["server"].get("port")),
1337
1543
  server_base_path=_normalize_base_path(cfg["server"].get("base_path", "")),
@@ -1402,6 +1608,12 @@ def _build_hub_config(config_path: Path, cfg: Dict[str, Any]) -> HubConfig:
1402
1608
  except ConfigPathError as exc:
1403
1609
  raise ConfigError(str(exc)) from exc
1404
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
+
1405
1617
  return HubConfig(
1406
1618
  raw=cfg,
1407
1619
  root=root,
@@ -1417,11 +1629,15 @@ def _build_hub_config(config_path: Path, cfg: Dict[str, Any]) -> HubConfig:
1417
1629
  repo_server_inherit=bool(hub_cfg.get("repo_server_inherit", True)),
1418
1630
  update_repo_url=str(hub_cfg.get("update_repo_url", "")),
1419
1631
  update_repo_ref=str(hub_cfg.get("update_repo_ref", "main")),
1632
+ update_skip_checks=update_skip_checks,
1420
1633
  app_server=_parse_app_server_config(
1421
1634
  cfg.get("app_server"),
1422
1635
  root,
1423
1636
  DEFAULT_HUB_CONFIG["app_server"],
1424
1637
  ),
1638
+ opencode=_parse_opencode_config(
1639
+ cfg.get("opencode"), root, DEFAULT_HUB_CONFIG.get("opencode")
1640
+ ),
1425
1641
  server_host=str(cfg["server"]["host"]),
1426
1642
  server_port=int(cfg["server"]["port"]),
1427
1643
  server_base_path=_normalize_base_path(cfg["server"].get("base_path", "")),
@@ -1501,6 +1717,12 @@ def _validate_app_server_config(cfg: Dict[str, Any]) -> None:
1501
1717
  app_server_cfg.get("state_root", ""), str
1502
1718
  ):
1503
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")
1504
1726
  for key in ("max_handles", "idle_ttl_seconds"):
1505
1727
  if key in app_server_cfg and app_server_cfg.get(key) is not None:
1506
1728
  if not isinstance(app_server_cfg.get(key), int):
@@ -1519,6 +1741,47 @@ def _validate_app_server_config(cfg: Dict[str, Any]) -> None:
1519
1741
  ):
1520
1742
  if not isinstance(app_server_cfg.get("request_timeout"), (int, float)):
1521
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")
1522
1785
  prompts = app_server_cfg.get("prompts")
1523
1786
  if prompts is not None:
1524
1787
  if not isinstance(prompts, dict):
@@ -1562,6 +1825,35 @@ def _validate_app_server_config(cfg: Dict[str, Any]) -> None:
1562
1825
  )
1563
1826
 
1564
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
+
1565
1857
  def _validate_agents_config(cfg: Dict[str, Any]) -> None:
1566
1858
  agents_cfg = cfg.get("agents")
1567
1859
  if agents_cfg is None:
@@ -1598,7 +1890,7 @@ def _validate_repo_config(cfg: Dict[str, Any], *, root: Path) -> None:
1598
1890
  )
1599
1891
  except ConfigPathError as exc:
1600
1892
  raise ConfigError(str(exc)) from exc
1601
- for key in ("todo", "progress", "opinions", "spec", "summary"):
1893
+ for key in ("active_context", "decisions", "spec"):
1602
1894
  if not isinstance(docs.get(key), str) or not docs[key]:
1603
1895
  raise ConfigError(f"docs.{key} must be a non-empty string path")
1604
1896
  _validate_agents_config(cfg)
@@ -1651,6 +1943,18 @@ def _validate_repo_config(cfg: Dict[str, Any], *, root: Path) -> None:
1651
1943
  val = runner.get(k)
1652
1944
  if val is not None and not isinstance(val, int):
1653
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")
1654
1958
  git = cfg.get("git")
1655
1959
  if not isinstance(git, dict):
1656
1960
  raise ConfigError("git section must be a mapping")
@@ -1674,69 +1978,7 @@ def _validate_repo_config(cfg: Dict[str, Any], *, root: Path) -> None:
1674
1978
  github.get("sync_agent_timeout_seconds"), int
1675
1979
  ):
1676
1980
  raise ConfigError("github.sync_agent_timeout_seconds must be an integer")
1677
- pr_flow = github.get("pr_flow")
1678
- if pr_flow is not None:
1679
- if not isinstance(pr_flow, dict):
1680
- raise ConfigError("github.pr_flow must be a mapping if provided")
1681
- if "enabled" in pr_flow and not isinstance(pr_flow.get("enabled"), bool):
1682
- raise ConfigError("github.pr_flow.enabled must be boolean")
1683
- if "max_cycles" in pr_flow and not isinstance(
1684
- pr_flow.get("max_cycles"), int
1685
- ):
1686
- raise ConfigError("github.pr_flow.max_cycles must be an integer")
1687
- if "stop_condition" in pr_flow and not isinstance(
1688
- pr_flow.get("stop_condition"), str
1689
- ):
1690
- raise ConfigError("github.pr_flow.stop_condition must be a string")
1691
- for key in ("max_implementation_runs", "max_wallclock_seconds"):
1692
- val = pr_flow.get(key)
1693
- if val is not None and not isinstance(val, int):
1694
- raise ConfigError(
1695
- f"github.pr_flow.{key} must be an integer or null"
1696
- )
1697
- review_cfg = pr_flow.get("review")
1698
- if review_cfg is not None:
1699
- if not isinstance(review_cfg, dict):
1700
- raise ConfigError("github.pr_flow.review must be a mapping")
1701
- for key in ("include_codex", "include_github", "include_checks"):
1702
- if key in review_cfg and not isinstance(review_cfg.get(key), bool):
1703
- raise ConfigError(
1704
- f"github.pr_flow.review.{key} must be boolean"
1705
- )
1706
- chatops_cfg = pr_flow.get("chatops")
1707
- if chatops_cfg is not None:
1708
- if not isinstance(chatops_cfg, dict):
1709
- raise ConfigError("github.pr_flow.chatops must be a mapping")
1710
- if "enabled" in chatops_cfg and not isinstance(
1711
- chatops_cfg.get("enabled"), bool
1712
- ):
1713
- raise ConfigError("github.pr_flow.chatops.enabled must be boolean")
1714
- if "poll_interval_seconds" in chatops_cfg and not isinstance(
1715
- chatops_cfg.get("poll_interval_seconds"), int
1716
- ):
1717
- raise ConfigError(
1718
- "github.pr_flow.chatops.poll_interval_seconds must be an integer"
1719
- )
1720
- for key in ("allow_users", "allow_associations"):
1721
- if key in chatops_cfg and not isinstance(
1722
- chatops_cfg.get(key), list
1723
- ):
1724
- raise ConfigError(
1725
- f"github.pr_flow.chatops.{key} must be a list"
1726
- )
1727
- if "ignore_bots" in chatops_cfg and not isinstance(
1728
- chatops_cfg.get("ignore_bots"), bool
1729
- ):
1730
- raise ConfigError(
1731
- "github.pr_flow.chatops.ignore_bots must be boolean"
1732
- )
1733
- if chatops_cfg.get("enabled", False):
1734
- allow_users = chatops_cfg.get("allow_users") or []
1735
- allow_assoc = chatops_cfg.get("allow_associations") or []
1736
- if not allow_users and not allow_assoc:
1737
- raise ConfigError(
1738
- "github.pr_flow.chatops.enabled requires at least one of allow_users or allow_associations to be non-empty"
1739
- )
1981
+
1740
1982
  server = cfg.get("server")
1741
1983
  if not isinstance(server, dict):
1742
1984
  raise ConfigError("server section must be a mapping")
@@ -1754,6 +1996,8 @@ def _validate_repo_config(cfg: Dict[str, Any], *, root: Path) -> None:
1754
1996
  raise ConfigError("server.auth_token_env must be a string if provided")
1755
1997
  _validate_server_security(server)
1756
1998
  _validate_app_server_config(cfg)
1999
+ _validate_opencode_config(cfg)
2000
+ _validate_update_config(cfg)
1757
2001
  notifications_cfg = cfg.get("notifications")
1758
2002
  if notifications_cfg is not None:
1759
2003
  if not isinstance(notifications_cfg, dict):
@@ -1897,6 +2141,8 @@ def _validate_hub_config(cfg: Dict[str, Any]) -> None:
1897
2141
  if "repo" in cfg:
1898
2142
  raise ConfigError("repo section is no longer supported; use repo_defaults")
1899
2143
  _validate_agents_config(cfg)
2144
+ _validate_opencode_config(cfg)
2145
+ _validate_update_config(cfg)
1900
2146
  repo_defaults = cfg.get("repo_defaults")
1901
2147
  if repo_defaults is not None:
1902
2148
  if not isinstance(repo_defaults, dict):
@@ -2195,6 +2441,29 @@ def _validate_telegram_bot_config(cfg: Dict[str, Any]) -> None:
2195
2441
  raise ConfigError(f"telegram_bot.shell.{key} must be an integer")
2196
2442
  if isinstance(value, int) and value <= 0:
2197
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")
2198
2467
  command_reg_cfg = telegram_cfg.get("command_registration")
2199
2468
  if command_reg_cfg is not None and not isinstance(command_reg_cfg, dict):
2200
2469
  raise ConfigError("telegram_bot.command_registration must be a mapping")
@@ -2232,10 +2501,20 @@ def _validate_telegram_bot_config(cfg: Dict[str, Any]) -> None:
2232
2501
  raise ConfigError(
2233
2502
  "telegram_bot.command_registration.scopes.language_code must be a string or null"
2234
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")
2235
2508
  if "state_file" in telegram_cfg and not isinstance(
2236
2509
  telegram_cfg.get("state_file"), str
2237
2510
  ):
2238
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")
2239
2518
  if "app_server_command" in telegram_cfg and not isinstance(
2240
2519
  telegram_cfg.get("app_server_command"), (list, str)
2241
2520
  ):
@@ -2252,6 +2531,17 @@ def _validate_telegram_bot_config(cfg: Dict[str, Any]) -> None:
2252
2531
  raise ConfigError(
2253
2532
  "telegram_bot.app_server.turn_timeout_seconds must be a number or null"
2254
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
+ )
2255
2545
  polling_cfg = telegram_cfg.get("polling")
2256
2546
  if polling_cfg is not None and not isinstance(polling_cfg, dict):
2257
2547
  raise ConfigError("telegram_bot.polling must be a mapping if provided")