codex-autorunner 1.1.0__py3-none-any.whl → 1.2.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 (127) hide show
  1. codex_autorunner/agents/opencode/client.py +113 -4
  2. codex_autorunner/agents/opencode/supervisor.py +4 -0
  3. codex_autorunner/agents/registry.py +17 -7
  4. codex_autorunner/bootstrap.py +219 -1
  5. codex_autorunner/core/__init__.py +17 -1
  6. codex_autorunner/core/about_car.py +114 -1
  7. codex_autorunner/core/app_server_threads.py +6 -0
  8. codex_autorunner/core/config.py +236 -1
  9. codex_autorunner/core/context_awareness.py +38 -0
  10. codex_autorunner/core/docs.py +0 -122
  11. codex_autorunner/core/filebox.py +265 -0
  12. codex_autorunner/core/flows/controller.py +71 -1
  13. codex_autorunner/core/flows/reconciler.py +4 -1
  14. codex_autorunner/core/flows/runtime.py +22 -0
  15. codex_autorunner/core/flows/store.py +61 -9
  16. codex_autorunner/core/flows/transition.py +23 -16
  17. codex_autorunner/core/flows/ux_helpers.py +18 -3
  18. codex_autorunner/core/flows/worker_process.py +32 -6
  19. codex_autorunner/core/hub.py +198 -41
  20. codex_autorunner/core/lifecycle_events.py +253 -0
  21. codex_autorunner/core/path_utils.py +2 -1
  22. codex_autorunner/core/pma_audit.py +224 -0
  23. codex_autorunner/core/pma_context.py +496 -0
  24. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  25. codex_autorunner/core/pma_lifecycle.py +527 -0
  26. codex_autorunner/core/pma_queue.py +367 -0
  27. codex_autorunner/core/pma_safety.py +221 -0
  28. codex_autorunner/core/pma_state.py +115 -0
  29. codex_autorunner/core/ports/agent_backend.py +2 -5
  30. codex_autorunner/core/ports/run_event.py +1 -4
  31. codex_autorunner/core/prompt.py +0 -80
  32. codex_autorunner/core/prompts.py +56 -172
  33. codex_autorunner/core/redaction.py +0 -4
  34. codex_autorunner/core/review_context.py +11 -9
  35. codex_autorunner/core/runner_controller.py +35 -33
  36. codex_autorunner/core/runner_state.py +147 -0
  37. codex_autorunner/core/runtime.py +829 -0
  38. codex_autorunner/core/sqlite_utils.py +13 -4
  39. codex_autorunner/core/state.py +7 -10
  40. codex_autorunner/core/state_roots.py +5 -0
  41. codex_autorunner/core/templates/__init__.py +39 -0
  42. codex_autorunner/core/templates/git_mirror.py +234 -0
  43. codex_autorunner/core/templates/provenance.py +56 -0
  44. codex_autorunner/core/templates/scan_cache.py +120 -0
  45. codex_autorunner/core/ticket_linter_cli.py +17 -0
  46. codex_autorunner/core/ticket_manager_cli.py +154 -92
  47. codex_autorunner/core/time_utils.py +11 -0
  48. codex_autorunner/core/types.py +18 -0
  49. codex_autorunner/core/utils.py +34 -6
  50. codex_autorunner/flows/review/service.py +23 -25
  51. codex_autorunner/flows/ticket_flow/definition.py +43 -1
  52. codex_autorunner/integrations/agents/__init__.py +2 -0
  53. codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
  54. codex_autorunner/integrations/agents/codex_backend.py +19 -8
  55. codex_autorunner/integrations/agents/runner.py +3 -8
  56. codex_autorunner/integrations/agents/wiring.py +8 -0
  57. codex_autorunner/integrations/telegram/doctor.py +228 -6
  58. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  59. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  60. codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
  61. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  62. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
  63. codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
  64. codex_autorunner/integrations/telegram/handlers/messages.py +26 -1
  65. codex_autorunner/integrations/telegram/helpers.py +1 -3
  66. codex_autorunner/integrations/telegram/runtime.py +9 -4
  67. codex_autorunner/integrations/telegram/service.py +30 -0
  68. codex_autorunner/integrations/telegram/state.py +38 -0
  69. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
  70. codex_autorunner/integrations/telegram/transport.py +10 -3
  71. codex_autorunner/integrations/templates/__init__.py +27 -0
  72. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  73. codex_autorunner/server.py +2 -2
  74. codex_autorunner/static/agentControls.js +21 -5
  75. codex_autorunner/static/app.js +115 -11
  76. codex_autorunner/static/chatUploads.js +137 -0
  77. codex_autorunner/static/docChatCore.js +185 -13
  78. codex_autorunner/static/fileChat.js +68 -40
  79. codex_autorunner/static/fileboxUi.js +159 -0
  80. codex_autorunner/static/hub.js +46 -81
  81. codex_autorunner/static/index.html +303 -24
  82. codex_autorunner/static/messages.js +82 -4
  83. codex_autorunner/static/notifications.js +255 -0
  84. codex_autorunner/static/pma.js +1167 -0
  85. codex_autorunner/static/settings.js +3 -0
  86. codex_autorunner/static/streamUtils.js +57 -0
  87. codex_autorunner/static/styles.css +9125 -6742
  88. codex_autorunner/static/templateReposSettings.js +225 -0
  89. codex_autorunner/static/ticketChatActions.js +165 -3
  90. codex_autorunner/static/ticketChatStream.js +17 -119
  91. codex_autorunner/static/ticketEditor.js +41 -13
  92. codex_autorunner/static/ticketTemplates.js +798 -0
  93. codex_autorunner/static/tickets.js +69 -19
  94. codex_autorunner/static/turnEvents.js +27 -0
  95. codex_autorunner/static/turnResume.js +33 -0
  96. codex_autorunner/static/utils.js +28 -0
  97. codex_autorunner/static/workspace.js +258 -44
  98. codex_autorunner/static/workspaceFileBrowser.js +6 -4
  99. codex_autorunner/surfaces/cli/cli.py +1465 -155
  100. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  101. codex_autorunner/surfaces/web/app.py +253 -49
  102. codex_autorunner/surfaces/web/routes/__init__.py +4 -0
  103. codex_autorunner/surfaces/web/routes/analytics.py +29 -22
  104. codex_autorunner/surfaces/web/routes/file_chat.py +317 -36
  105. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  106. codex_autorunner/surfaces/web/routes/flows.py +219 -29
  107. codex_autorunner/surfaces/web/routes/messages.py +70 -39
  108. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  109. codex_autorunner/surfaces/web/routes/repos.py +1 -1
  110. codex_autorunner/surfaces/web/routes/shared.py +0 -3
  111. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  112. codex_autorunner/surfaces/web/runner_manager.py +2 -2
  113. codex_autorunner/surfaces/web/schemas.py +70 -18
  114. codex_autorunner/tickets/agent_pool.py +27 -0
  115. codex_autorunner/tickets/files.py +33 -16
  116. codex_autorunner/tickets/lint.py +50 -0
  117. codex_autorunner/tickets/models.py +3 -0
  118. codex_autorunner/tickets/outbox.py +41 -5
  119. codex_autorunner/tickets/runner.py +350 -69
  120. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/METADATA +15 -19
  121. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/RECORD +125 -94
  122. codex_autorunner/core/adapter_utils.py +0 -21
  123. codex_autorunner/core/engine.py +0 -3302
  124. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  125. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  126. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  127. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -2,6 +2,7 @@ import asyncio
2
2
  import logging
3
3
  import os
4
4
  import shlex
5
+ import sys
5
6
  import threading
6
7
  from contextlib import ExitStack, asynccontextmanager
7
8
  from dataclasses import dataclass
@@ -18,6 +19,7 @@ from starlette.types import ASGIApp
18
19
 
19
20
  from ...agents.opencode.supervisor import OpenCodeSupervisor
20
21
  from ...agents.registry import validate_agent_id
22
+ from ...bootstrap import ensure_hub_car_shim
21
23
  from ...core.app_server_threads import (
22
24
  AppServerThreadRegistry,
23
25
  default_app_server_threads_path,
@@ -34,7 +36,6 @@ from ...core.config import (
34
36
  load_repo_config,
35
37
  resolve_env_for_root,
36
38
  )
37
- from ...core.engine import Engine, LockError
38
39
  from ...core.flows.models import FlowRunStatus
39
40
  from ...core.flows.reconciler import reconcile_flow_runs
40
41
  from ...core.flows.store import FlowStore
@@ -42,6 +43,7 @@ from ...core.hub import HubSupervisor
42
43
  from ...core.logging_utils import safe_log, setup_rotating_logger
43
44
  from ...core.optional_dependencies import require_optional_dependencies
44
45
  from ...core.request_context import get_request_id
46
+ from ...core.runtime import LockError, RuntimeContext
45
47
  from ...core.state import load_state, persist_session_registry
46
48
  from ...core.usage import (
47
49
  UsageError,
@@ -56,6 +58,7 @@ from ...core.utils import (
56
58
  set_repo_root_context,
57
59
  )
58
60
  from ...housekeeping import run_housekeeping_once
61
+ from ...integrations.agents import build_backend_orchestrator
59
62
  from ...integrations.agents.wiring import (
60
63
  build_agent_backend_factory,
61
64
  build_app_server_supervisor_factory,
@@ -65,7 +68,8 @@ from ...integrations.app_server.env import build_app_server_env
65
68
  from ...integrations.app_server.event_buffer import AppServerEventBuffer
66
69
  from ...integrations.app_server.supervisor import WorkspaceAppServerSupervisor
67
70
  from ...manifest import load_manifest
68
- from ...tickets.files import safe_relpath
71
+ from ...tickets.files import list_ticket_paths, safe_relpath, ticket_is_done
72
+ from ...tickets.models import Dispatch
69
73
  from ...tickets.outbox import parse_dispatch, resolve_outbox_paths
70
74
  from ...voice import VoiceConfig, VoiceService
71
75
  from .hub_jobs import HubJobManager
@@ -77,6 +81,8 @@ from .middleware import (
77
81
  SecurityHeadersMiddleware,
78
82
  )
79
83
  from .routes import build_repo_router
84
+ from .routes.filebox import build_hub_filebox_routes
85
+ from .routes.pma import build_pma_routes
80
86
  from .routes.system import build_system_routes
81
87
  from .runner_manager import RunnerManager
82
88
  from .schemas import (
@@ -101,7 +107,7 @@ from .terminal_sessions import parse_tui_idle_seconds, prune_terminal_registry
101
107
  class AppContext:
102
108
  base_path: str
103
109
  env: Mapping[str, str]
104
- engine: Engine
110
+ engine: RuntimeContext
105
111
  manager: RunnerManager
106
112
  app_server_supervisor: Optional[WorkspaceAppServerSupervisor]
107
113
  app_server_prune_interval: Optional[float]
@@ -135,6 +141,10 @@ class HubAppContext:
135
141
  job_manager: HubJobManager
136
142
  app_server_supervisor: Optional[WorkspaceAppServerSupervisor]
137
143
  app_server_prune_interval: Optional[float]
144
+ app_server_threads: AppServerThreadRegistry
145
+ app_server_events: AppServerEventBuffer
146
+ opencode_supervisor: Optional[OpenCodeSupervisor]
147
+ opencode_prune_interval: Optional[float]
138
148
  static_dir: Path
139
149
  static_assets_context: Optional[object]
140
150
  asset_version: str
@@ -309,6 +319,7 @@ def _build_opencode_supervisor(
309
319
  env: Mapping[str, str],
310
320
  subagent_models: Optional[Mapping[str, str]] = None,
311
321
  session_stall_timeout_seconds: Optional[float] = None,
322
+ max_text_chars: Optional[int] = None,
312
323
  ) -> tuple[Optional[OpenCodeSupervisor], Optional[float]]:
313
324
  supervisor = build_opencode_supervisor(
314
325
  opencode_command=opencode_command,
@@ -319,6 +330,7 @@ def _build_opencode_supervisor(
319
330
  max_handles=config.max_handles,
320
331
  idle_ttl_seconds=config.idle_ttl_seconds,
321
332
  session_stall_timeout_seconds=session_stall_timeout_seconds,
333
+ max_text_chars=max_text_chars,
322
334
  base_env=env,
323
335
  subagent_models=subagent_models,
324
336
  )
@@ -349,12 +361,11 @@ def _build_app_context(
349
361
  if base_path is not None
350
362
  else config.server_base_path
351
363
  )
352
- engine = Engine(
364
+ backend_orchestrator = build_backend_orchestrator(config.root, config)
365
+ engine = RuntimeContext(
353
366
  config.root,
354
367
  config=config,
355
- backend_factory=build_agent_backend_factory(config.root, config),
356
- app_server_supervisor_factory=build_app_server_supervisor_factory(config),
357
- agent_id_validator=validate_agent_id,
368
+ backend_orchestrator=backend_orchestrator,
358
369
  )
359
370
  manager = RunnerManager(engine)
360
371
  voice_config = VoiceConfig.from_raw(config.voice, env=env)
@@ -484,6 +495,7 @@ def _build_app_context(
484
495
  env=env,
485
496
  subagent_models=subagent_models,
486
497
  session_stall_timeout_seconds=config.opencode.session_stall_timeout_seconds,
498
+ max_text_chars=config.opencode.max_text_chars,
487
499
  )
488
500
  voice_service: Optional[VoiceService]
489
501
  if voice_missing_reason:
@@ -653,6 +665,7 @@ def _build_hub_context(
653
665
  config,
654
666
  backend_factory_builder=build_agent_backend_factory,
655
667
  app_server_supervisor_factory_builder=build_app_server_supervisor_factory,
668
+ backend_orchestrator_builder=build_backend_orchestrator,
656
669
  agent_id_validator=validate_agent_id,
657
670
  )
658
671
  logger = setup_rotating_logger(f"hub[{config.root}]", config.server_log)
@@ -669,10 +682,42 @@ def _build_hub_context(
669
682
  logging.INFO,
670
683
  f"Hub app ready at {config.root}",
671
684
  )
685
+ try:
686
+ ensure_hub_car_shim(config.root, python_executable=sys.executable)
687
+ except Exception as exc:
688
+ safe_log(
689
+ logger,
690
+ logging.WARNING,
691
+ "Failed to ensure hub car shim",
692
+ exc=exc,
693
+ )
694
+ app_server_events = AppServerEventBuffer()
672
695
  app_server_supervisor, app_server_prune_interval = _build_app_server_supervisor(
673
696
  config.app_server,
674
697
  logger=logger,
675
698
  event_prefix="hub.app_server",
699
+ notification_handler=app_server_events.handle_notification,
700
+ )
701
+ app_server_threads = AppServerThreadRegistry(
702
+ default_app_server_threads_path(config.root)
703
+ )
704
+ opencode_command = config.agent_serve_command("opencode")
705
+ try:
706
+ opencode_binary = config.agent_binary("opencode")
707
+ except ConfigError:
708
+ opencode_binary = None
709
+ agent_config = config.agents.get("opencode")
710
+ subagent_models = agent_config.subagent_models if agent_config else None
711
+ opencode_supervisor, opencode_prune_interval = _build_opencode_supervisor(
712
+ config.app_server,
713
+ workspace_root=config.root,
714
+ opencode_binary=opencode_binary,
715
+ opencode_command=opencode_command,
716
+ logger=logger,
717
+ env=resolve_env_for_root(config.root),
718
+ subagent_models=subagent_models,
719
+ session_stall_timeout_seconds=config.opencode.session_stall_timeout_seconds,
720
+ max_text_chars=config.opencode.max_text_chars,
676
721
  )
677
722
  static_dir, static_context = materialize_static_assets(
678
723
  config.static_assets.cache_root,
@@ -699,6 +744,10 @@ def _build_hub_context(
699
744
  job_manager=HubJobManager(logger=logger),
700
745
  app_server_supervisor=app_server_supervisor,
701
746
  app_server_prune_interval=app_server_prune_interval,
747
+ app_server_threads=app_server_threads,
748
+ app_server_events=app_server_events,
749
+ opencode_supervisor=opencode_supervisor,
750
+ opencode_prune_interval=opencode_prune_interval,
702
751
  static_dir=static_dir,
703
752
  static_assets_context=static_context,
704
753
  asset_version=asset_version(static_dir),
@@ -713,9 +762,14 @@ def _apply_hub_context(app: FastAPI, context: HubAppContext) -> None:
713
762
  app.state.job_manager = context.job_manager
714
763
  app.state.app_server_supervisor = context.app_server_supervisor
715
764
  app.state.app_server_prune_interval = context.app_server_prune_interval
765
+ app.state.app_server_threads = context.app_server_threads
766
+ app.state.app_server_events = context.app_server_events
767
+ app.state.opencode_supervisor = context.opencode_supervisor
768
+ app.state.opencode_prune_interval = context.opencode_prune_interval
716
769
  app.state.static_dir = context.static_dir
717
770
  app.state.static_assets_context = context.static_assets_context
718
771
  app.state.asset_version = context.asset_version
772
+ app.state.hub_supervisor = context.supervisor
719
773
 
720
774
 
721
775
  def _app_lifespan(context: AppContext):
@@ -1097,6 +1151,11 @@ def create_hub_app(
1097
1151
  app.state.static_assets_lock = threading.Lock()
1098
1152
  app.state.hub_static_assets = None
1099
1153
  app.mount("/static", static_files, name="static")
1154
+ raw_config = getattr(context.config, "raw", {})
1155
+ pma_config = raw_config.get("pma", {}) if isinstance(raw_config, dict) else {}
1156
+ if isinstance(pma_config, dict) and pma_config.get("enabled"):
1157
+ app.include_router(build_pma_routes())
1158
+ app.include_router(build_hub_filebox_routes())
1100
1159
  mounted_repos: set[str] = set()
1101
1160
  mount_errors: dict[str, str] = {}
1102
1161
  repo_apps: dict[str, ASGIApp] = {}
@@ -1322,6 +1381,53 @@ def create_hub_app(
1322
1381
  repo_dict["mounted"] = False
1323
1382
  return repo_dict
1324
1383
 
1384
+ def _get_ticket_flow_summary(repo_path: Path) -> Optional[dict]:
1385
+ """Get ticket flow summary for a repo (status, done/total, step).
1386
+
1387
+ Returns None if no ticket flow exists or repo is not initialized.
1388
+ """
1389
+ db_path = repo_path / ".codex-autorunner" / "flows.db"
1390
+ if not db_path.exists():
1391
+ return None
1392
+ try:
1393
+ config = load_repo_config(repo_path)
1394
+ with FlowStore(db_path, durable=config.durable_writes) as store:
1395
+ # Get the latest ticket_flow run (any status)
1396
+ runs = store.list_flow_runs(flow_type="ticket_flow")
1397
+ if not runs:
1398
+ return None
1399
+ latest = runs[0] # Already sorted by created_at DESC
1400
+
1401
+ # Count tickets
1402
+ ticket_dir = repo_path / ".codex-autorunner" / "tickets"
1403
+ total = 0
1404
+ done = 0
1405
+ for path in list_ticket_paths(ticket_dir):
1406
+ total += 1
1407
+ try:
1408
+ if ticket_is_done(path):
1409
+ done += 1
1410
+ except Exception:
1411
+ continue
1412
+
1413
+ if total == 0:
1414
+ return None
1415
+
1416
+ # Extract current step from ticket_engine state
1417
+ state = latest.state if isinstance(latest.state, dict) else {}
1418
+ engine = state.get("ticket_engine") if isinstance(state, dict) else {}
1419
+ engine = engine if isinstance(engine, dict) else {}
1420
+ current_step = engine.get("total_turns")
1421
+
1422
+ return {
1423
+ "status": latest.status.value,
1424
+ "done_count": done,
1425
+ "total_count": total,
1426
+ "current_step": current_step,
1427
+ }
1428
+ except Exception:
1429
+ return None
1430
+
1325
1431
  initial_snapshots = context.supervisor.scan()
1326
1432
  for snap in initial_snapshots:
1327
1433
  if snap.initialized and snap.exists_on_disk:
@@ -1372,6 +1478,24 @@ def create_hub_app(
1372
1478
  )
1373
1479
 
1374
1480
  asyncio.create_task(_app_server_prune_loop())
1481
+ opencode_supervisor = getattr(app.state, "opencode_supervisor", None)
1482
+ opencode_prune_interval = getattr(app.state, "opencode_prune_interval", None)
1483
+ if opencode_supervisor is not None and opencode_prune_interval:
1484
+
1485
+ async def _opencode_prune_loop():
1486
+ while True:
1487
+ await asyncio.sleep(opencode_prune_interval)
1488
+ try:
1489
+ await opencode_supervisor.prune_idle()
1490
+ except Exception as exc:
1491
+ safe_log(
1492
+ app.state.logger,
1493
+ logging.WARNING,
1494
+ "Hub opencode prune task failed",
1495
+ exc,
1496
+ )
1497
+
1498
+ asyncio.create_task(_opencode_prune_loop())
1375
1499
  mount_lock = await _get_mount_lock()
1376
1500
  async with mount_lock:
1377
1501
  for prefix in list(mount_order):
@@ -1398,6 +1522,17 @@ def create_hub_app(
1398
1522
  "Hub app-server shutdown failed",
1399
1523
  exc,
1400
1524
  )
1525
+ opencode_supervisor = getattr(app.state, "opencode_supervisor", None)
1526
+ if opencode_supervisor is not None:
1527
+ try:
1528
+ await opencode_supervisor.close_all()
1529
+ except Exception as exc:
1530
+ safe_log(
1531
+ app.state.logger,
1532
+ logging.WARNING,
1533
+ "Hub opencode shutdown failed",
1534
+ exc,
1535
+ )
1401
1536
  static_context = getattr(app.state, "static_assets_context", None)
1402
1537
  if static_context is not None:
1403
1538
  static_context.close()
@@ -1501,6 +1636,27 @@ def create_hub_app(
1501
1636
  history_dir = outbox_paths.dispatch_history_dir
1502
1637
  if not history_dir.exists() or not history_dir.is_dir():
1503
1638
  return None
1639
+
1640
+ def _dispatch_dict(dispatch: Dispatch) -> dict:
1641
+ return {
1642
+ "mode": dispatch.mode,
1643
+ "title": dispatch.title,
1644
+ "body": dispatch.body,
1645
+ "extra": dispatch.extra,
1646
+ "is_handoff": dispatch.is_handoff,
1647
+ }
1648
+
1649
+ def _list_files(dispatch_dir: Path) -> list[str]:
1650
+ files: list[str] = []
1651
+ for child in sorted(dispatch_dir.iterdir(), key=lambda p: p.name):
1652
+ if child.name.startswith("."):
1653
+ continue
1654
+ if child.name == "DISPATCH.md":
1655
+ continue
1656
+ if child.is_file():
1657
+ files.append(child.name)
1658
+ return files
1659
+
1504
1660
  seq_dirs: list[Path] = []
1505
1661
  for child in history_dir.iterdir():
1506
1662
  if not child.is_dir():
@@ -1510,40 +1666,74 @@ def create_hub_app(
1510
1666
  seq_dirs.append(child)
1511
1667
  if not seq_dirs:
1512
1668
  return None
1513
- latest_dir = sorted(seq_dirs, key=lambda p: p.name)[-1]
1514
- seq = int(latest_dir.name)
1515
- dispatch_path = latest_dir / "DISPATCH.md"
1516
- dispatch, errors = parse_dispatch(dispatch_path)
1517
- if errors or dispatch is None:
1518
- return {
1519
- "seq": seq,
1520
- "dir": safe_relpath(latest_dir, repo_root),
1521
- "dispatch": None,
1522
- "errors": errors,
1523
- "files": [],
1524
- }
1525
- files: list[str] = []
1526
- for child in sorted(latest_dir.iterdir(), key=lambda p: p.name):
1527
- if child.name.startswith("."):
1528
- continue
1529
- if child.name == "DISPATCH.md":
1669
+
1670
+ seq_dirs = sorted(seq_dirs, key=lambda p: p.name, reverse=True)
1671
+ handoff_candidate: Optional[dict] = None
1672
+ non_summary_candidate: Optional[dict] = None
1673
+ turn_summary_candidate: Optional[dict] = None
1674
+ error_candidate: Optional[dict] = None
1675
+
1676
+ for seq_dir in seq_dirs:
1677
+ seq = int(seq_dir.name)
1678
+ dispatch_path = seq_dir / "DISPATCH.md"
1679
+ dispatch, errors = parse_dispatch(dispatch_path)
1680
+ if errors or dispatch is None:
1681
+ if error_candidate is None:
1682
+ error_candidate = {
1683
+ "seq": seq,
1684
+ "dir": seq_dir,
1685
+ "errors": errors,
1686
+ }
1530
1687
  continue
1531
- if child.is_file():
1532
- files.append(child.name)
1533
- dispatch_dict = {
1534
- "mode": dispatch.mode,
1535
- "title": dispatch.title,
1536
- "body": dispatch.body,
1537
- "extra": dispatch.extra,
1538
- "is_handoff": dispatch.is_handoff,
1539
- }
1540
- return {
1541
- "seq": seq,
1542
- "dir": safe_relpath(latest_dir, repo_root),
1543
- "dispatch": dispatch_dict,
1688
+ candidate = {"seq": seq, "dir": seq_dir, "dispatch": dispatch}
1689
+ if dispatch.is_handoff and handoff_candidate is None:
1690
+ handoff_candidate = candidate
1691
+ if (
1692
+ dispatch.mode != "turn_summary"
1693
+ and non_summary_candidate is None
1694
+ ):
1695
+ non_summary_candidate = candidate
1696
+ if (
1697
+ dispatch.mode == "turn_summary"
1698
+ and turn_summary_candidate is None
1699
+ ):
1700
+ turn_summary_candidate = candidate
1701
+ if (
1702
+ handoff_candidate
1703
+ and non_summary_candidate
1704
+ and turn_summary_candidate
1705
+ ):
1706
+ break
1707
+
1708
+ selected = (
1709
+ handoff_candidate or non_summary_candidate or turn_summary_candidate
1710
+ )
1711
+ if not selected:
1712
+ if error_candidate:
1713
+ return {
1714
+ "seq": error_candidate["seq"],
1715
+ "dir": safe_relpath(error_candidate["dir"], repo_root),
1716
+ "dispatch": None,
1717
+ "errors": error_candidate["errors"],
1718
+ "files": [],
1719
+ }
1720
+ return None
1721
+
1722
+ selected_dir = selected["dir"]
1723
+ dispatch = selected["dispatch"]
1724
+ result = {
1725
+ "seq": selected["seq"],
1726
+ "dir": safe_relpath(selected_dir, repo_root),
1727
+ "dispatch": _dispatch_dict(dispatch),
1544
1728
  "errors": [],
1545
- "files": files,
1729
+ "files": _list_files(selected_dir),
1546
1730
  }
1731
+ if turn_summary_candidate is not None:
1732
+ result["turn_summary_seq"] = turn_summary_candidate["seq"]
1733
+ result["turn_summary"] = _dispatch_dict(
1734
+ turn_summary_candidate["dispatch"]
1735
+ )
1736
+ return result
1547
1737
  except Exception:
1548
1738
  return None
1549
1739
 
@@ -1561,11 +1751,11 @@ def create_hub_app(
1561
1751
  if not db_path.exists():
1562
1752
  continue
1563
1753
  try:
1564
- store = FlowStore(db_path)
1565
- store.initialize()
1566
- paused = store.list_flow_runs(
1567
- flow_type="ticket_flow", status=FlowRunStatus.PAUSED
1568
- )
1754
+ config = load_repo_config(repo_root)
1755
+ with FlowStore(db_path, durable=config.durable_writes) as store:
1756
+ paused = store.list_flow_runs(
1757
+ flow_type="ticket_flow", status=FlowRunStatus.PAUSED
1758
+ )
1569
1759
  except Exception:
1570
1760
  continue
1571
1761
  if not paused:
@@ -1603,11 +1793,18 @@ def create_hub_app(
1603
1793
  safe_log(app.state.logger, logging.INFO, "Hub list_repos")
1604
1794
  snapshots = await asyncio.to_thread(context.supervisor.list_repos)
1605
1795
  await _refresh_mounts(snapshots)
1796
+
1797
+ def _enrich_repo(snap):
1798
+ repo_dict = _add_mount_info(snap.to_dict(context.config.root))
1799
+ if snap.initialized and snap.exists_on_disk:
1800
+ repo_dict["ticket_flow"] = _get_ticket_flow_summary(snap.path)
1801
+ else:
1802
+ repo_dict["ticket_flow"] = None
1803
+ return repo_dict
1804
+
1606
1805
  return {
1607
1806
  "last_scan_at": context.supervisor.state.last_scan_at,
1608
- "repos": [
1609
- _add_mount_info(repo.to_dict(context.config.root)) for repo in snapshots
1610
- ],
1807
+ "repos": [_enrich_repo(snap) for snap in snapshots],
1611
1808
  }
1612
1809
 
1613
1810
  @app.get("/hub/version")
@@ -1619,11 +1816,18 @@ def create_hub_app(
1619
1816
  safe_log(app.state.logger, logging.INFO, "Hub scan_repos")
1620
1817
  snapshots = await asyncio.to_thread(context.supervisor.scan)
1621
1818
  await _refresh_mounts(snapshots)
1819
+
1820
+ def _enrich_repo(snap):
1821
+ repo_dict = _add_mount_info(snap.to_dict(context.config.root))
1822
+ if snap.initialized and snap.exists_on_disk:
1823
+ repo_dict["ticket_flow"] = _get_ticket_flow_summary(snap.path)
1824
+ else:
1825
+ repo_dict["ticket_flow"] = None
1826
+ return repo_dict
1827
+
1622
1828
  return {
1623
1829
  "last_scan_at": context.supervisor.state.last_scan_at,
1624
- "repos": [
1625
- _add_mount_info(repo.to_dict(context.config.root)) for repo in snapshots
1626
- ],
1830
+ "repos": [_enrich_repo(snap) for snap in snapshots],
1627
1831
  }
1628
1832
 
1629
1833
  @app.post("/hub/jobs/scan", response_model=HubJobResponse)
@@ -26,6 +26,7 @@ from .app_server import build_app_server_routes
26
26
  from .archive import build_archive_routes
27
27
  from .base import build_base_routes, build_frontend_routes
28
28
  from .file_chat import build_file_chat_routes
29
+ from .filebox import build_filebox_routes
29
30
  from .flows import build_flow_routes
30
31
  from .messages import build_messages_routes
31
32
  from .repos import build_repos_routes
@@ -33,6 +34,7 @@ from .review import build_review_routes
33
34
  from .sessions import build_sessions_routes
34
35
  from .settings import build_settings_routes
35
36
  from .system import build_system_routes
37
+ from .templates import build_templates_routes
36
38
  from .terminal_images import build_terminal_image_routes
37
39
  from .usage import build_usage_routes
38
40
  from .voice import build_voice_routes
@@ -59,6 +61,7 @@ def build_repo_router(static_dir: Path) -> APIRouter:
59
61
  router.include_router(build_app_server_routes())
60
62
  router.include_router(build_workspace_routes())
61
63
  router.include_router(build_flow_routes())
64
+ router.include_router(build_filebox_routes())
62
65
  router.include_router(build_file_chat_routes())
63
66
  router.include_router(build_messages_routes())
64
67
  router.include_router(build_repos_routes())
@@ -66,6 +69,7 @@ def build_repo_router(static_dir: Path) -> APIRouter:
66
69
  router.include_router(build_sessions_routes())
67
70
  router.include_router(build_settings_routes())
68
71
  router.include_router(build_system_routes())
72
+ router.include_router(build_templates_routes())
69
73
  router.include_router(build_terminal_image_routes())
70
74
  router.include_router(build_usage_routes())
71
75
  router.include_router(build_voice_routes())
@@ -14,7 +14,7 @@ from typing import Any, Dict, Optional
14
14
 
15
15
  from fastapi import APIRouter
16
16
 
17
- from ....core.flows.models import FlowRunRecord, FlowRunStatus
17
+ from ....core.flows.models import FlowEventType, FlowRunRecord, FlowRunStatus
18
18
  from ....core.flows.store import FlowStore
19
19
  from ....core.utils import find_repo_root
20
20
  from ....tickets.files import list_ticket_paths, read_ticket, ticket_is_done
@@ -26,18 +26,6 @@ def _flows_db_path(repo_root: Path) -> Path:
26
26
  return repo_root / ".codex-autorunner" / "flows.db"
27
27
 
28
28
 
29
- def _load_flow_store(repo_root: Path) -> Optional[FlowStore]:
30
- db_path = _flows_db_path(repo_root)
31
- if not db_path.exists():
32
- return None
33
- store = FlowStore(db_path)
34
- try:
35
- store.initialize()
36
- except Exception:
37
- return None
38
- return store
39
-
40
-
41
29
  def _select_primary_run(records: list[FlowRunRecord]) -> Optional[FlowRunRecord]:
42
30
  """Select the primary run for analytics display.
43
31
 
@@ -154,19 +142,19 @@ def _aggregate_diff_stats(dispatch_history_dir: Path) -> Dict[str, int]:
154
142
 
155
143
 
156
144
  def _build_summary(repo_root: Path) -> Dict[str, Any]:
145
+ from ....core.config import load_repo_config
146
+
157
147
  ticket_dir = repo_root / ".codex-autorunner" / "tickets"
158
- store = _load_flow_store(repo_root)
148
+ db_path = _flows_db_path(repo_root)
159
149
  records: list[FlowRunRecord] = []
160
- if store:
150
+ if db_path.exists():
161
151
  try:
162
- records = store.list_flow_runs(flow_type="ticket_flow")
152
+ with FlowStore(
153
+ db_path, durable=load_repo_config(repo_root).durable_writes
154
+ ) as store:
155
+ records = store.list_flow_runs(flow_type="ticket_flow")
163
156
  except Exception:
164
157
  records = []
165
- finally:
166
- try:
167
- store.close()
168
- except Exception:
169
- pass
170
158
 
171
159
  run_record = _select_primary_run(records)
172
160
 
@@ -226,7 +214,26 @@ def _build_summary(repo_root: Path) -> Dict[str, Any]:
226
214
  )
227
215
  turns["dispatches"] = _count_history_dirs(outbox_paths.dispatch_history_dir)
228
216
  turns["replies"] = _count_history_dirs(reply_paths.reply_history_dir)
229
- turns["diff_stats"] = _aggregate_diff_stats(outbox_paths.dispatch_history_dir)
217
+ # Diff stats are now stored in FlowStore as DIFF_UPDATED events.
218
+ # Fallback to legacy dispatch history parsing if FlowStore query fails.
219
+ try:
220
+ with FlowStore(
221
+ db_path, durable=load_repo_config(repo_root).durable_writes
222
+ ) as store:
223
+ events = store.get_events_by_type(
224
+ run_record.id, FlowEventType.DIFF_UPDATED
225
+ )
226
+ totals = {"insertions": 0, "deletions": 0, "files_changed": 0}
227
+ for ev in events:
228
+ data = ev.data or {}
229
+ totals["insertions"] += int(data.get("insertions") or 0)
230
+ totals["deletions"] += int(data.get("deletions") or 0)
231
+ totals["files_changed"] += int(data.get("files_changed") or 0)
232
+ turns["diff_stats"] = totals
233
+ except Exception:
234
+ turns["diff_stats"] = _aggregate_diff_stats(
235
+ outbox_paths.dispatch_history_dir
236
+ )
230
237
 
231
238
  # If current ticket is known, read its frontmatter to pick agent id when available.
232
239
  if current_ticket: