codex-autorunner 1.0.0__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/constants.py +3 -0
  4. codex_autorunner/agents/opencode/harness.py +6 -1
  5. codex_autorunner/agents/opencode/runtime.py +59 -18
  6. codex_autorunner/agents/registry.py +22 -3
  7. codex_autorunner/bootstrap.py +7 -3
  8. codex_autorunner/cli.py +5 -1174
  9. codex_autorunner/codex_cli.py +20 -84
  10. codex_autorunner/core/__init__.py +4 -0
  11. codex_autorunner/core/about_car.py +6 -1
  12. codex_autorunner/core/app_server_ids.py +59 -0
  13. codex_autorunner/core/app_server_threads.py +11 -2
  14. codex_autorunner/core/app_server_utils.py +165 -0
  15. codex_autorunner/core/archive.py +349 -0
  16. codex_autorunner/core/codex_runner.py +6 -2
  17. codex_autorunner/core/config.py +197 -3
  18. codex_autorunner/core/drafts.py +58 -4
  19. codex_autorunner/core/engine.py +1329 -680
  20. codex_autorunner/core/exceptions.py +4 -0
  21. codex_autorunner/core/flows/controller.py +25 -1
  22. codex_autorunner/core/flows/models.py +13 -0
  23. codex_autorunner/core/flows/reasons.py +52 -0
  24. codex_autorunner/core/flows/reconciler.py +131 -0
  25. codex_autorunner/core/flows/runtime.py +35 -4
  26. codex_autorunner/core/flows/store.py +83 -0
  27. codex_autorunner/core/flows/transition.py +5 -0
  28. codex_autorunner/core/flows/ux_helpers.py +257 -0
  29. codex_autorunner/core/git_utils.py +62 -0
  30. codex_autorunner/core/hub.py +121 -7
  31. codex_autorunner/core/notifications.py +14 -2
  32. codex_autorunner/core/ports/__init__.py +28 -0
  33. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +11 -3
  34. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  35. codex_autorunner/{integrations/agents → core/ports}/run_event.py +22 -2
  36. codex_autorunner/core/state_roots.py +57 -0
  37. codex_autorunner/core/supervisor_protocol.py +15 -0
  38. codex_autorunner/core/text_delta_coalescer.py +54 -0
  39. codex_autorunner/core/ticket_linter_cli.py +201 -0
  40. codex_autorunner/core/ticket_manager_cli.py +432 -0
  41. codex_autorunner/core/update.py +4 -5
  42. codex_autorunner/core/update_paths.py +28 -0
  43. codex_autorunner/core/usage.py +164 -12
  44. codex_autorunner/core/utils.py +91 -9
  45. codex_autorunner/flows/review/__init__.py +17 -0
  46. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  47. codex_autorunner/flows/ticket_flow/definition.py +9 -2
  48. codex_autorunner/integrations/agents/__init__.py +9 -19
  49. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  50. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  51. codex_autorunner/integrations/agents/codex_backend.py +158 -17
  52. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  53. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  54. codex_autorunner/integrations/agents/runner.py +91 -0
  55. codex_autorunner/integrations/agents/wiring.py +271 -0
  56. codex_autorunner/integrations/app_server/client.py +7 -60
  57. codex_autorunner/integrations/app_server/env.py +2 -107
  58. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  59. codex_autorunner/integrations/telegram/adapter.py +65 -0
  60. codex_autorunner/integrations/telegram/config.py +46 -0
  61. codex_autorunner/integrations/telegram/constants.py +1 -1
  62. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1203 -66
  64. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +4 -3
  65. codex_autorunner/integrations/telegram/handlers/commands_spec.py +8 -2
  66. codex_autorunner/integrations/telegram/handlers/messages.py +1 -0
  67. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  68. codex_autorunner/integrations/telegram/helpers.py +24 -1
  69. codex_autorunner/integrations/telegram/service.py +15 -10
  70. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +329 -40
  71. codex_autorunner/integrations/telegram/transport.py +3 -1
  72. codex_autorunner/routes/__init__.py +37 -76
  73. codex_autorunner/routes/agents.py +2 -137
  74. codex_autorunner/routes/analytics.py +2 -238
  75. codex_autorunner/routes/app_server.py +2 -131
  76. codex_autorunner/routes/base.py +2 -596
  77. codex_autorunner/routes/file_chat.py +4 -833
  78. codex_autorunner/routes/flows.py +4 -977
  79. codex_autorunner/routes/messages.py +4 -456
  80. codex_autorunner/routes/repos.py +2 -196
  81. codex_autorunner/routes/review.py +2 -147
  82. codex_autorunner/routes/sessions.py +2 -175
  83. codex_autorunner/routes/settings.py +2 -168
  84. codex_autorunner/routes/shared.py +2 -275
  85. codex_autorunner/routes/system.py +4 -193
  86. codex_autorunner/routes/usage.py +2 -86
  87. codex_autorunner/routes/voice.py +2 -119
  88. codex_autorunner/routes/workspace.py +2 -270
  89. codex_autorunner/server.py +2 -2
  90. codex_autorunner/static/agentControls.js +40 -11
  91. codex_autorunner/static/app.js +11 -3
  92. codex_autorunner/static/archive.js +826 -0
  93. codex_autorunner/static/archiveApi.js +37 -0
  94. codex_autorunner/static/autoRefresh.js +7 -7
  95. codex_autorunner/static/dashboard.js +224 -171
  96. codex_autorunner/static/hub.js +112 -94
  97. codex_autorunner/static/index.html +80 -33
  98. codex_autorunner/static/messages.js +486 -83
  99. codex_autorunner/static/preserve.js +17 -0
  100. codex_autorunner/static/settings.js +125 -6
  101. codex_autorunner/static/smartRefresh.js +52 -0
  102. codex_autorunner/static/styles.css +1373 -101
  103. codex_autorunner/static/tabs.js +152 -11
  104. codex_autorunner/static/terminal.js +18 -0
  105. codex_autorunner/static/ticketEditor.js +99 -5
  106. codex_autorunner/static/tickets.js +760 -87
  107. codex_autorunner/static/utils.js +11 -0
  108. codex_autorunner/static/workspace.js +133 -40
  109. codex_autorunner/static/workspaceFileBrowser.js +9 -9
  110. codex_autorunner/surfaces/__init__.py +5 -0
  111. codex_autorunner/surfaces/cli/__init__.py +6 -0
  112. codex_autorunner/surfaces/cli/cli.py +1224 -0
  113. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  114. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  115. codex_autorunner/surfaces/web/__init__.py +1 -0
  116. codex_autorunner/surfaces/web/app.py +2019 -0
  117. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  118. codex_autorunner/surfaces/web/middleware.py +587 -0
  119. codex_autorunner/surfaces/web/pty_session.py +370 -0
  120. codex_autorunner/surfaces/web/review.py +6 -0
  121. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  122. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  123. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  124. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  125. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  126. codex_autorunner/surfaces/web/routes/base.py +615 -0
  127. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  128. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  129. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  130. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  131. codex_autorunner/surfaces/web/routes/review.py +148 -0
  132. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  133. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  134. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  135. codex_autorunner/surfaces/web/routes/system.py +196 -0
  136. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  137. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  138. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  139. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  140. codex_autorunner/surfaces/web/schemas.py +417 -0
  141. codex_autorunner/surfaces/web/static_assets.py +490 -0
  142. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  143. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  144. codex_autorunner/tickets/__init__.py +8 -1
  145. codex_autorunner/tickets/agent_pool.py +26 -4
  146. codex_autorunner/tickets/files.py +6 -2
  147. codex_autorunner/tickets/models.py +3 -1
  148. codex_autorunner/tickets/outbox.py +12 -0
  149. codex_autorunner/tickets/runner.py +63 -5
  150. codex_autorunner/web/__init__.py +5 -1
  151. codex_autorunner/web/app.py +2 -1949
  152. codex_autorunner/web/hub_jobs.py +2 -191
  153. codex_autorunner/web/middleware.py +2 -586
  154. codex_autorunner/web/pty_session.py +2 -369
  155. codex_autorunner/web/runner_manager.py +2 -24
  156. codex_autorunner/web/schemas.py +2 -376
  157. codex_autorunner/web/static_assets.py +4 -441
  158. codex_autorunner/web/static_refresh.py +2 -85
  159. codex_autorunner/web/terminal_sessions.py +2 -77
  160. codex_autorunner/workspace/paths.py +49 -33
  161. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  162. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  163. codex_autorunner/core/static_assets.py +0 -55
  164. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  165. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  166. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  167. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +0 -0
  168. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  169. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  170. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -95,6 +95,9 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
95
95
  "prev_run_max_chars": 6000,
96
96
  "template": ".codex-autorunner/prompt.txt",
97
97
  },
98
+ "ui": {
99
+ "editor": "vi",
100
+ },
98
101
  "security": {
99
102
  "redact_run_logs": True,
100
103
  },
@@ -128,6 +131,9 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
128
131
  },
129
132
  },
130
133
  },
134
+ "autorunner": {
135
+ "reuse_session": False,
136
+ },
131
137
  "ticket_flow": {
132
138
  "approval_mode": "yolo",
133
139
  # Keep ticket_flow deterministic by default; surfaces can tighten this.
@@ -192,6 +198,11 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
192
198
  "opencode": {
193
199
  "session_stall_timeout_seconds": 60,
194
200
  },
201
+ "usage": {
202
+ "cache_scope": "global",
203
+ "global_cache_root": None,
204
+ "repo_cache_path": ".codex-autorunner/usage/usage_series_cache.json",
205
+ },
195
206
  "server": {
196
207
  "host": "127.0.0.1",
197
208
  "port": 4173,
@@ -205,6 +216,7 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
205
216
  "enabled": "auto",
206
217
  "events": ["run_finished", "run_error", "tui_idle"],
207
218
  "tui_idle_seconds": 60,
219
+ "timeout_seconds": 5.0,
208
220
  "discord": {
209
221
  "webhook_url_env": "CAR_DISCORD_WEBHOOK_URL",
210
222
  },
@@ -419,7 +431,9 @@ REPO_DEFAULT_KEYS = {
419
431
  "docs",
420
432
  "codex",
421
433
  "prompt",
434
+ "ui",
422
435
  "runner",
436
+ "autorunner",
423
437
  "ticket_flow",
424
438
  "git",
425
439
  "github",
@@ -430,6 +444,7 @@ REPO_DEFAULT_KEYS = {
430
444
  "server_log",
431
445
  "review",
432
446
  "opencode",
447
+ "usage",
433
448
  }
434
449
  DEFAULT_REPO_DEFAULTS = {
435
450
  key: json.loads(json.dumps(DEFAULT_REPO_CONFIG[key])) for key in REPO_DEFAULT_KEYS
@@ -444,6 +459,7 @@ REPO_SHARED_KEYS = {
444
459
  "static_assets",
445
460
  "housekeeping",
446
461
  "update",
462
+ "usage",
447
463
  }
448
464
 
449
465
  DEFAULT_HUB_CONFIG: Dict[str, Any] = {
@@ -603,6 +619,11 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
603
619
  "opencode": {
604
620
  "session_stall_timeout_seconds": 60,
605
621
  },
622
+ "usage": {
623
+ "cache_scope": "global",
624
+ "global_cache_root": None,
625
+ "repo_cache_path": ".codex-autorunner/usage/usage_series_cache.json",
626
+ },
606
627
  "server": {
607
628
  "host": "127.0.0.1",
608
629
  "port": 4173,
@@ -791,6 +812,13 @@ class OpenCodeConfig:
791
812
  session_stall_timeout_seconds: Optional[float]
792
813
 
793
814
 
815
+ @dataclasses.dataclass
816
+ class UsageConfig:
817
+ cache_scope: str
818
+ global_cache_root: Path
819
+ repo_cache_path: Path
820
+
821
+
794
822
  @dataclasses.dataclass(frozen=True)
795
823
  class AgentConfig:
796
824
  binary: str
@@ -819,12 +847,14 @@ class RepoConfig:
819
847
  runner_stop_after_runs: Optional[int]
820
848
  runner_max_wallclock_seconds: Optional[int]
821
849
  runner_no_progress_threshold: int
850
+ autorunner_reuse_session: bool
822
851
  ticket_flow: Dict[str, Any]
823
852
  git_auto_commit: bool
824
853
  git_commit_message_template: str
825
854
  update_skip_checks: bool
826
855
  app_server: AppServerConfig
827
856
  opencode: OpenCodeConfig
857
+ usage: UsageConfig
828
858
  server_host: str
829
859
  server_port: int
830
860
  server_base_path: str
@@ -875,6 +905,7 @@ class HubConfig:
875
905
  update_skip_checks: bool
876
906
  app_server: AppServerConfig
877
907
  opencode: OpenCodeConfig
908
+ usage: UsageConfig
878
909
  server_host: str
879
910
  server_port: int
880
911
  server_base_path: str
@@ -1243,6 +1274,40 @@ def _parse_opencode_config(
1243
1274
  return OpenCodeConfig(session_stall_timeout_seconds=stall_timeout_seconds)
1244
1275
 
1245
1276
 
1277
+ def _parse_usage_config(
1278
+ cfg: Optional[Dict[str, Any]],
1279
+ root: Path,
1280
+ defaults: Optional[Dict[str, Any]],
1281
+ ) -> UsageConfig:
1282
+ cfg = cfg if isinstance(cfg, dict) else {}
1283
+ defaults = defaults if isinstance(defaults, dict) else {}
1284
+ cache_scope = str(cfg.get("cache_scope", defaults.get("cache_scope", "global")))
1285
+ cache_scope = cache_scope.lower().strip() or "global"
1286
+ global_cache_raw = cfg.get("global_cache_root", defaults.get("global_cache_root"))
1287
+ if global_cache_raw is None:
1288
+ global_cache_raw = os.environ.get("CODEX_HOME", "~/.codex")
1289
+ global_cache_root = resolve_config_path(
1290
+ global_cache_raw,
1291
+ root,
1292
+ allow_absolute=True,
1293
+ allow_home=True,
1294
+ scope="usage.global_cache_root",
1295
+ )
1296
+ repo_cache_raw = cfg.get("repo_cache_path", defaults.get("repo_cache_path"))
1297
+ if repo_cache_raw is None:
1298
+ repo_cache_raw = ".codex-autorunner/usage/usage_series_cache.json"
1299
+ repo_cache_path = resolve_config_path(
1300
+ repo_cache_raw,
1301
+ root,
1302
+ scope="usage.repo_cache_path",
1303
+ )
1304
+ return UsageConfig(
1305
+ cache_scope=cache_scope,
1306
+ global_cache_root=global_cache_root,
1307
+ repo_cache_path=repo_cache_path,
1308
+ )
1309
+
1310
+
1246
1311
  def _parse_agents_config(
1247
1312
  cfg: Optional[Dict[str, Any]], defaults: Dict[str, Any]
1248
1313
  ) -> Dict[str, AgentConfig]:
@@ -1378,6 +1443,54 @@ def resolve_env_for_root(
1378
1443
  return env
1379
1444
 
1380
1445
 
1446
+ VOICE_ENV_OVERRIDES = (
1447
+ "CODEX_AUTORUNNER_VOICE_ENABLED",
1448
+ "CODEX_AUTORUNNER_VOICE_PROVIDER",
1449
+ "CODEX_AUTORUNNER_VOICE_LATENCY",
1450
+ "CODEX_AUTORUNNER_VOICE_CHUNK_MS",
1451
+ "CODEX_AUTORUNNER_VOICE_SAMPLE_RATE",
1452
+ "CODEX_AUTORUNNER_VOICE_WARN_REMOTE",
1453
+ "CODEX_AUTORUNNER_VOICE_MAX_MS",
1454
+ "CODEX_AUTORUNNER_VOICE_SILENCE_MS",
1455
+ "CODEX_AUTORUNNER_VOICE_MIN_HOLD_MS",
1456
+ )
1457
+
1458
+ TELEGRAM_ENV_OVERRIDES = (
1459
+ "CAR_OPENCODE_COMMAND",
1460
+ "CAR_TELEGRAM_APP_SERVER_COMMAND",
1461
+ )
1462
+
1463
+
1464
+ def collect_env_overrides(
1465
+ *,
1466
+ env: Optional[Mapping[str, str]] = None,
1467
+ include_telegram: bool = False,
1468
+ ) -> list[str]:
1469
+ source = env if env is not None else os.environ
1470
+ overrides: list[str] = []
1471
+
1472
+ def _has_value(key: str) -> bool:
1473
+ value = source.get(key)
1474
+ if value is None:
1475
+ return False
1476
+ return str(value).strip() != ""
1477
+
1478
+ if source.get("CODEX_AUTORUNNER_SKIP_UPDATE_CHECKS") == "1":
1479
+ overrides.append("CODEX_AUTORUNNER_SKIP_UPDATE_CHECKS")
1480
+ if _has_value("CODEX_DISABLE_APP_SERVER_AUTORESTART_FOR_TESTS"):
1481
+ overrides.append("CODEX_DISABLE_APP_SERVER_AUTORESTART_FOR_TESTS")
1482
+ if _has_value("CAR_GLOBAL_STATE_ROOT"):
1483
+ overrides.append("CAR_GLOBAL_STATE_ROOT")
1484
+ for key in VOICE_ENV_OVERRIDES:
1485
+ if _has_value(key):
1486
+ overrides.append(key)
1487
+ if include_telegram:
1488
+ for key in TELEGRAM_ENV_OVERRIDES:
1489
+ if _has_value(key):
1490
+ overrides.append(key)
1491
+ return overrides
1492
+
1493
+
1381
1494
  def load_hub_config_data(config_path: Path) -> Dict[str, Any]:
1382
1495
  """Load, merge, and return a raw hub config dict for the given config path."""
1383
1496
  load_dotenv_for_root(config_path.parent.parent.resolve())
@@ -1417,7 +1530,7 @@ def load_hub_config(start: Path) -> HubConfig:
1417
1530
  """Load the nearest hub config walking upward from the provided path."""
1418
1531
  config_path = _resolve_hub_config_path(start)
1419
1532
  merged = load_hub_config_data(config_path)
1420
- _validate_hub_config(merged)
1533
+ _validate_hub_config(merged, root=config_path.parent.parent.resolve())
1421
1534
  return _build_hub_config(config_path, merged)
1422
1535
 
1423
1536
 
@@ -1463,7 +1576,7 @@ def load_repo_config(start: Path, hub_path: Optional[Path] = None) -> RepoConfig
1463
1576
  repo_root = _resolve_repo_root(start)
1464
1577
  hub_config_path = _resolve_hub_path_for_repo(repo_root, hub_path)
1465
1578
  hub_config = load_hub_config_data(hub_config_path)
1466
- _validate_hub_config(hub_config)
1579
+ _validate_hub_config(hub_config, root=hub_config_path.parent.parent.resolve())
1467
1580
  hub = _build_hub_config(hub_config_path, hub_config)
1468
1581
  return derive_repo_config(hub, repo_root)
1469
1582
 
@@ -1507,6 +1620,14 @@ def _build_repo_config(config_path: Path, cfg: Dict[str, Any]) -> RepoConfig:
1507
1620
  Dict[str, Any], update_cfg if isinstance(update_cfg, dict) else {}
1508
1621
  )
1509
1622
  update_skip_checks = bool(update_cfg.get("skip_checks", False))
1623
+ autorunner_cfg = cfg.get("autorunner")
1624
+ autorunner_cfg = cast(
1625
+ Dict[str, Any], autorunner_cfg if isinstance(autorunner_cfg, dict) else {}
1626
+ )
1627
+ reuse_session_value = autorunner_cfg.get("reuse_session")
1628
+ autorunner_reuse_session = (
1629
+ bool(reuse_session_value) if reuse_session_value is not None else False
1630
+ )
1510
1631
  return RepoConfig(
1511
1632
  raw=cfg,
1512
1633
  root=root,
@@ -1525,6 +1646,7 @@ def _build_repo_config(config_path: Path, cfg: Dict[str, Any]) -> RepoConfig:
1525
1646
  runner_stop_after_runs=cfg["runner"].get("stop_after_runs"),
1526
1647
  runner_max_wallclock_seconds=cfg["runner"].get("max_wallclock_seconds"),
1527
1648
  runner_no_progress_threshold=int(cfg["runner"].get("no_progress_threshold", 3)),
1649
+ autorunner_reuse_session=autorunner_reuse_session,
1528
1650
  git_auto_commit=bool(cfg["git"].get("auto_commit", False)),
1529
1651
  git_commit_message_template=str(cfg["git"].get("commit_message_template")),
1530
1652
  update_skip_checks=update_skip_checks,
@@ -1537,6 +1659,9 @@ def _build_repo_config(config_path: Path, cfg: Dict[str, Any]) -> RepoConfig:
1537
1659
  opencode=_parse_opencode_config(
1538
1660
  cfg.get("opencode"), root, DEFAULT_REPO_CONFIG.get("opencode")
1539
1661
  ),
1662
+ usage=_parse_usage_config(
1663
+ cfg.get("usage"), root, DEFAULT_REPO_CONFIG.get("usage")
1664
+ ),
1540
1665
  security=security_cfg,
1541
1666
  server_host=str(cfg["server"].get("host")),
1542
1667
  server_port=int(cfg["server"].get("port")),
@@ -1638,6 +1763,9 @@ def _build_hub_config(config_path: Path, cfg: Dict[str, Any]) -> HubConfig:
1638
1763
  opencode=_parse_opencode_config(
1639
1764
  cfg.get("opencode"), root, DEFAULT_HUB_CONFIG.get("opencode")
1640
1765
  ),
1766
+ usage=_parse_usage_config(
1767
+ cfg.get("usage"), root, DEFAULT_HUB_CONFIG.get("usage")
1768
+ ),
1641
1769
  server_host=str(cfg["server"]["host"]),
1642
1770
  server_port=int(cfg["server"]["port"]),
1643
1771
  server_base_path=_normalize_base_path(cfg["server"].get("base_path", "")),
@@ -1854,6 +1982,47 @@ def _validate_update_config(cfg: Dict[str, Any]) -> None:
1854
1982
  raise ConfigError("update.skip_checks must be boolean or null")
1855
1983
 
1856
1984
 
1985
+ def _validate_usage_config(cfg: Dict[str, Any], *, root: Path) -> None:
1986
+ usage_cfg = cfg.get("usage")
1987
+ if usage_cfg is None:
1988
+ return
1989
+ if not isinstance(usage_cfg, dict):
1990
+ raise ConfigError("usage section must be a mapping if provided")
1991
+ cache_scope = usage_cfg.get("cache_scope")
1992
+ if cache_scope is not None and not isinstance(cache_scope, str):
1993
+ raise ConfigError("usage.cache_scope must be a string if provided")
1994
+ if isinstance(cache_scope, str):
1995
+ scope_val = cache_scope.strip().lower()
1996
+ if scope_val and scope_val not in {"global", "repo"}:
1997
+ raise ConfigError("usage.cache_scope must be 'global' or 'repo'")
1998
+ global_cache_root = usage_cfg.get("global_cache_root")
1999
+ if global_cache_root is not None:
2000
+ if not isinstance(global_cache_root, str):
2001
+ raise ConfigError("usage.global_cache_root must be a string or null")
2002
+ try:
2003
+ resolve_config_path(
2004
+ global_cache_root,
2005
+ root,
2006
+ allow_absolute=True,
2007
+ allow_home=True,
2008
+ scope="usage.global_cache_root",
2009
+ )
2010
+ except ConfigPathError as exc:
2011
+ raise ConfigError(str(exc)) from exc
2012
+ repo_cache_path = usage_cfg.get("repo_cache_path")
2013
+ if repo_cache_path is not None:
2014
+ if not isinstance(repo_cache_path, str):
2015
+ raise ConfigError("usage.repo_cache_path must be a string or null")
2016
+ try:
2017
+ resolve_config_path(
2018
+ repo_cache_path,
2019
+ root,
2020
+ scope="usage.repo_cache_path",
2021
+ )
2022
+ except ConfigPathError as exc:
2023
+ raise ConfigError(str(exc)) from exc
2024
+
2025
+
1857
2026
  def _validate_agents_config(cfg: Dict[str, Any]) -> None:
1858
2027
  agents_cfg = cfg.get("agents")
1859
2028
  if agents_cfg is None:
@@ -1943,6 +2112,13 @@ def _validate_repo_config(cfg: Dict[str, Any], *, root: Path) -> None:
1943
2112
  val = runner.get(k)
1944
2113
  if val is not None and not isinstance(val, int):
1945
2114
  raise ConfigError(f"runner.{k} must be an integer or null")
2115
+ autorunner_cfg = cfg.get("autorunner")
2116
+ if autorunner_cfg is not None and not isinstance(autorunner_cfg, dict):
2117
+ raise ConfigError("autorunner section must be a mapping if provided")
2118
+ if isinstance(autorunner_cfg, dict):
2119
+ reuse_session = autorunner_cfg.get("reuse_session")
2120
+ if reuse_session is not None and not isinstance(reuse_session, bool):
2121
+ raise ConfigError("autorunner.reuse_session must be boolean or null")
1946
2122
  ticket_flow_cfg = cfg.get("ticket_flow")
1947
2123
  if ticket_flow_cfg is not None and not isinstance(ticket_flow_cfg, dict):
1948
2124
  raise ConfigError("ticket_flow section must be a mapping if provided")
@@ -1955,6 +2131,12 @@ def _validate_repo_config(cfg: Dict[str, Any], *, root: Path) -> None:
1955
2131
  ticket_flow_cfg.get("default_approval_decision"), str
1956
2132
  ):
1957
2133
  raise ConfigError("ticket_flow.default_approval_decision must be a string")
2134
+ ui_cfg = cfg.get("ui")
2135
+ if ui_cfg is not None and not isinstance(ui_cfg, dict):
2136
+ raise ConfigError("ui section must be a mapping if provided")
2137
+ if isinstance(ui_cfg, dict):
2138
+ if "editor" in ui_cfg and not isinstance(ui_cfg.get("editor"), str):
2139
+ raise ConfigError("ui.editor must be a string if provided")
1958
2140
  git = cfg.get("git")
1959
2141
  if not isinstance(git, dict):
1960
2142
  raise ConfigError("git section must be a mapping")
@@ -1998,6 +2180,7 @@ def _validate_repo_config(cfg: Dict[str, Any], *, root: Path) -> None:
1998
2180
  _validate_app_server_config(cfg)
1999
2181
  _validate_opencode_config(cfg)
2000
2182
  _validate_update_config(cfg)
2183
+ _validate_usage_config(cfg, root=root)
2001
2184
  notifications_cfg = cfg.get("notifications")
2002
2185
  if notifications_cfg is not None:
2003
2186
  if not isinstance(notifications_cfg, dict):
@@ -2029,6 +2212,16 @@ def _validate_repo_config(cfg: Dict[str, Any], *, root: Path) -> None:
2029
2212
  raise ConfigError(
2030
2213
  "notifications.tui_idle_seconds must be >= 0 if provided"
2031
2214
  )
2215
+ timeout_seconds = notifications_cfg.get("timeout_seconds")
2216
+ if timeout_seconds is not None:
2217
+ if not isinstance(timeout_seconds, (int, float)):
2218
+ raise ConfigError(
2219
+ "notifications.timeout_seconds must be a number if provided"
2220
+ )
2221
+ if timeout_seconds <= 0:
2222
+ raise ConfigError(
2223
+ "notifications.timeout_seconds must be > 0 if provided"
2224
+ )
2032
2225
  discord_cfg = notifications_cfg.get("discord")
2033
2226
  if discord_cfg is not None and not isinstance(discord_cfg, dict):
2034
2227
  raise ConfigError("notifications.discord must be a mapping if provided")
@@ -2134,7 +2327,7 @@ def _validate_repo_config(cfg: Dict[str, Any], *, root: Path) -> None:
2134
2327
  _validate_telegram_bot_config(cfg)
2135
2328
 
2136
2329
 
2137
- def _validate_hub_config(cfg: Dict[str, Any]) -> None:
2330
+ def _validate_hub_config(cfg: Dict[str, Any], *, root: Path) -> None:
2138
2331
  _validate_version(cfg)
2139
2332
  if cfg.get("mode") != "hub":
2140
2333
  raise ConfigError("Hub config must set mode: hub")
@@ -2143,6 +2336,7 @@ def _validate_hub_config(cfg: Dict[str, Any]) -> None:
2143
2336
  _validate_agents_config(cfg)
2144
2337
  _validate_opencode_config(cfg)
2145
2338
  _validate_update_config(cfg)
2339
+ _validate_usage_config(cfg, root=root)
2146
2340
  repo_defaults = cfg.get("repo_defaults")
2147
2341
  if repo_defaults is not None:
2148
2342
  if not isinstance(repo_defaults, dict):
@@ -2,12 +2,18 @@ from __future__ import annotations
2
2
 
3
3
  import hashlib
4
4
  import json
5
+ import logging
6
+ from datetime import datetime, timezone
5
7
  from pathlib import Path
6
8
  from typing import Any, Dict, Optional
7
9
 
8
10
  from .utils import atomic_write
9
11
 
10
12
  FILE_CHAT_STATE_NAME = "file_chat_state.json"
13
+ FILE_CHAT_STATE_CORRUPT_SUFFIX = ".corrupt"
14
+ FILE_CHAT_STATE_NOTICE_SUFFIX = ".corrupt.json"
15
+
16
+ logger = logging.getLogger(__name__)
11
17
 
12
18
 
13
19
  def state_path(repo_root: Path) -> Path:
@@ -23,12 +29,19 @@ def load_state(repo_root: Path) -> Dict[str, Any]:
23
29
  if not path.exists():
24
30
  return {"drafts": {}}
25
31
  try:
26
- data = json.loads(path.read_text(encoding="utf-8"))
27
- if isinstance(data, dict):
28
- return data
32
+ raw = path.read_text(encoding="utf-8")
33
+ except OSError as exc:
34
+ logger.warning("Failed to read file chat state at %s: %s", path, exc)
29
35
  return {"drafts": {}}
30
- except Exception:
36
+ try:
37
+ data = json.loads(raw)
38
+ except json.JSONDecodeError as exc:
39
+ _handle_corrupt_state(path, str(exc))
40
+ return {"drafts": {}}
41
+ if not isinstance(data, dict):
42
+ _handle_corrupt_state(path, f"Expected JSON object, got {type(data).__name__}")
31
43
  return {"drafts": {}}
44
+ return data
32
45
 
33
46
 
34
47
  def save_state(repo_root: Path, state: Dict[str, Any]) -> None:
@@ -80,3 +93,44 @@ def invalidate_drafts_for_path(repo_root: Path, rel_path: str) -> list[str]:
80
93
  if removed_keys:
81
94
  save_drafts(repo_root, drafts)
82
95
  return removed_keys
96
+
97
+
98
+ def _stamp() -> str:
99
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
100
+
101
+
102
+ def _notice_path(path: Path) -> Path:
103
+ return path.with_name(f"{path.name}{FILE_CHAT_STATE_NOTICE_SUFFIX}")
104
+
105
+
106
+ def _handle_corrupt_state(path: Path, detail: str) -> None:
107
+ stamp = _stamp()
108
+ backup_path = path.with_name(f"{path.name}{FILE_CHAT_STATE_CORRUPT_SUFFIX}.{stamp}")
109
+ path.parent.mkdir(parents=True, exist_ok=True)
110
+ try:
111
+ path.replace(backup_path)
112
+ backup_value = str(backup_path)
113
+ except OSError:
114
+ backup_value = ""
115
+ notice = {
116
+ "status": "corrupt",
117
+ "message": "Draft state reset due to corrupted file_chat_state.json.",
118
+ "detail": detail,
119
+ "detected_at": stamp,
120
+ "backup_path": backup_value,
121
+ }
122
+ notice_path = _notice_path(path)
123
+ try:
124
+ atomic_write(notice_path, json.dumps(notice, indent=2) + "\n")
125
+ except Exception:
126
+ logger.warning("Failed to write draft corruption notice at %s", notice_path)
127
+ try:
128
+ atomic_write(path, json.dumps({"drafts": {}}, indent=2) + "\n")
129
+ except Exception:
130
+ logger.warning("Failed to reset draft state at %s", path)
131
+ logger.warning(
132
+ "Corrupted file chat state detected; backup=%s notice=%s detail=%s",
133
+ backup_value or "unavailable",
134
+ notice_path,
135
+ detail,
136
+ )