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
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from ..workspace.paths import read_workspace_doc, workspace_doc_path
8
+ from .files import list_ticket_paths, safe_relpath
9
+
10
+
11
+ class SpecIngestTicketsError(Exception):
12
+ """Raised when workspace spec → tickets ingest fails."""
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SpecIngestTicketsResult:
17
+ created: int
18
+ first_ticket_path: Optional[str] = None
19
+
20
+
21
+ def _ticket_dir(repo_root: Path) -> Path:
22
+ return repo_root / ".codex-autorunner" / "tickets"
23
+
24
+
25
+ def _ticket_path(repo_root: Path, index: int) -> Path:
26
+ return _ticket_dir(repo_root) / f"TICKET-{index:03d}.md"
27
+
28
+
29
+ def ingest_workspace_spec_to_tickets(repo_root: Path) -> SpecIngestTicketsResult:
30
+ """Generate initial tickets from `.codex-autorunner/workspace/spec.md`.
31
+
32
+ Behavior is intentionally conservative:
33
+ - Refuses to run if any tickets already exist.
34
+ - Writes exactly one bootstrap ticket that asks the agent to break down the spec.
35
+ """
36
+
37
+ spec_path = workspace_doc_path(repo_root, "spec")
38
+ spec_text = read_workspace_doc(repo_root, "spec")
39
+ if not spec_text.strip():
40
+ raise SpecIngestTicketsError(
41
+ f"Workspace spec is missing or empty at {safe_relpath(spec_path, repo_root)}"
42
+ )
43
+
44
+ ticket_dir = _ticket_dir(repo_root)
45
+ existing = list_ticket_paths(ticket_dir)
46
+ if existing:
47
+ raise SpecIngestTicketsError(
48
+ "Tickets already exist; refusing to generate tickets from spec."
49
+ )
50
+
51
+ ticket_dir.mkdir(parents=True, exist_ok=True)
52
+ ticket_path = _ticket_path(repo_root, 1)
53
+
54
+ rel_spec = safe_relpath(spec_path, repo_root)
55
+ template = f"""---
56
+ agent: codex
57
+ done: false
58
+ title: Bootstrap tickets from workspace spec
59
+ goal: Read workspace spec and create follow-up tickets
60
+ ---
61
+
62
+ You are the first ticket in a workspace-driven workflow.
63
+
64
+ - Read `{rel_spec}`.
65
+ - Break the work into additional `TICKET-00X.md` files under `.codex-autorunner/tickets/`.
66
+ - Keep this ticket open until the follow-up tickets exist and are coherent.
67
+ - Keep tickets small and single-purpose; prefer many small tickets over one big one.
68
+
69
+ When you need ongoing context, you may also consult (optional):
70
+ - `.codex-autorunner/workspace/active_context.md`
71
+ - `.codex-autorunner/workspace/decisions.md`
72
+ """
73
+
74
+ ticket_path.write_text(template, encoding="utf-8")
75
+ return SpecIngestTicketsResult(
76
+ created=1, first_ticket_path=safe_relpath(ticket_path, repo_root)
77
+ )
@@ -11,6 +11,7 @@ from typing import Mapping, Optional
11
11
  from fastapi import FastAPI, HTTPException
12
12
  from fastapi.responses import HTMLResponse
13
13
  from fastapi.staticfiles import StaticFiles
14
+ from starlette.middleware.base import BaseHTTPMiddleware
14
15
  from starlette.middleware.gzip import GZipMiddleware
15
16
  from starlette.routing import Mount
16
17
  from starlette.types import ASGIApp
@@ -32,13 +33,13 @@ from ..core.config import (
32
33
  load_repo_config,
33
34
  resolve_env_for_root,
34
35
  )
35
- from ..core.doc_chat import DocChatService
36
36
  from ..core.engine import Engine, LockError
37
+ from ..core.flows.models import FlowRunStatus
38
+ from ..core.flows.store import FlowStore
37
39
  from ..core.hub import HubSupervisor
38
40
  from ..core.logging_utils import safe_log, setup_rotating_logger
39
41
  from ..core.optional_dependencies import require_optional_dependencies
40
42
  from ..core.request_context import get_request_id
41
- from ..core.snapshot import SnapshotService
42
43
  from ..core.state import load_state, persist_session_registry
43
44
  from ..core.usage import (
44
45
  UsageError,
@@ -49,17 +50,18 @@ from ..core.usage import (
49
50
  )
50
51
  from ..core.utils import (
51
52
  build_opencode_supervisor,
53
+ reset_repo_root_context,
54
+ set_repo_root_context,
52
55
  )
53
56
  from ..housekeeping import run_housekeeping_once
54
57
  from ..integrations.app_server.client import ApprovalHandler, NotificationHandler
55
58
  from ..integrations.app_server.env import build_app_server_env
56
59
  from ..integrations.app_server.supervisor import WorkspaceAppServerSupervisor
57
- from ..integrations.github.chatops import GitHubChatOpsPoller
58
- from ..integrations.github.pr_flow import PrFlowManager
59
60
  from ..manifest import load_manifest
60
61
  from ..routes import build_repo_router
61
62
  from ..routes.system import build_system_routes
62
- from ..spec_ingest import SpecIngestService
63
+ from ..tickets.files import safe_relpath
64
+ from ..tickets.outbox import parse_dispatch, resolve_outbox_paths
63
65
  from ..voice import VoiceConfig, VoiceService
64
66
  from .hub_jobs import HubJobManager
65
67
  from .middleware import (
@@ -94,9 +96,6 @@ class AppContext:
94
96
  env: Mapping[str, str]
95
97
  engine: Engine
96
98
  manager: RunnerManager
97
- doc_chat: DocChatService
98
- spec_ingest: SpecIngestService
99
- snapshot_service: SnapshotService
100
99
  app_server_supervisor: Optional[WorkspaceAppServerSupervisor]
101
100
  app_server_prune_interval: Optional[float]
102
101
  app_server_threads: AppServerThreadRegistry
@@ -211,6 +210,25 @@ def _extract_turn_context(params: dict) -> tuple[Optional[str], Optional[str]]:
211
210
  return thread_id, turn_id
212
211
 
213
212
 
213
+ def _path_is_allowed_for_file_write(path: str) -> bool:
214
+ normalized = (path or "").strip()
215
+ if not normalized:
216
+ return False
217
+ # Canonical allowlist for all AI-assisted file edits via app-server approval:
218
+ # - tickets: .codex-autorunner/tickets/**
219
+ # - workspace docs: .codex-autorunner/workspace/**
220
+ allowed_prefixes = (
221
+ ".codex-autorunner/tickets/",
222
+ ".codex-autorunner/workspace/",
223
+ )
224
+ if normalized in (".codex-autorunner/tickets", ".codex-autorunner/workspace"):
225
+ return True
226
+ return any(
227
+ normalized == prefix.rstrip("/") or normalized.startswith(prefix)
228
+ for prefix in allowed_prefixes
229
+ )
230
+
231
+
214
232
  def _build_app_server_supervisor(
215
233
  config: AppServerConfig,
216
234
  *,
@@ -246,9 +264,19 @@ def _build_app_server_supervisor(
246
264
  state_root=config.state_root,
247
265
  env_builder=_env_builder,
248
266
  logger=logger,
267
+ auto_restart=config.auto_restart,
249
268
  max_handles=config.max_handles,
250
269
  idle_ttl_seconds=config.idle_ttl_seconds,
251
270
  request_timeout=config.request_timeout,
271
+ turn_stall_timeout_seconds=config.turn_stall_timeout_seconds,
272
+ turn_stall_poll_interval_seconds=config.turn_stall_poll_interval_seconds,
273
+ turn_stall_recovery_min_interval_seconds=config.turn_stall_recovery_min_interval_seconds,
274
+ max_message_bytes=config.client.max_message_bytes,
275
+ oversize_preview_bytes=config.client.oversize_preview_bytes,
276
+ max_oversize_drain_bytes=config.client.max_oversize_drain_bytes,
277
+ restart_backoff_initial_seconds=config.client.restart_backoff_initial_seconds,
278
+ restart_backoff_max_seconds=config.client.restart_backoff_max_seconds,
279
+ restart_backoff_jitter_ratio=config.client.restart_backoff_jitter_ratio,
252
280
  notification_handler=notification_handler,
253
281
  approval_handler=approval_handler,
254
282
  )
@@ -273,6 +301,7 @@ def _build_opencode_supervisor(
273
301
  logger: logging.Logger,
274
302
  env: Mapping[str, str],
275
303
  subagent_models: Optional[Mapping[str, str]] = None,
304
+ session_stall_timeout_seconds: Optional[float] = None,
276
305
  ) -> tuple[Optional[OpenCodeSupervisor], Optional[float]]:
277
306
  supervisor = build_opencode_supervisor(
278
307
  opencode_command=opencode_command,
@@ -282,6 +311,7 @@ def _build_opencode_supervisor(
282
311
  request_timeout=config.request_timeout,
283
312
  max_handles=config.max_handles,
284
313
  idle_ttl_seconds=config.idle_ttl_seconds,
314
+ session_stall_timeout_seconds=session_stall_timeout_seconds,
285
315
  base_env=env,
286
316
  subagent_models=subagent_models,
287
317
  )
@@ -352,19 +382,8 @@ def _build_app_context(
352
382
  f"Repo server ready at {engine.repo_root}",
353
383
  )
354
384
  app_server_events = AppServerEventBuffer()
355
- allowed_doc_paths = {
356
- path
357
- for kind in ("todo", "progress", "opinions", "spec", "summary")
358
- for path in [
359
- _normalize_approval_path(
360
- str(engine.config.doc_path(kind).relative_to(engine.config.root)),
361
- engine.config.root,
362
- )
363
- ]
364
- if path
365
- }
366
385
 
367
- async def _doc_chat_approval_handler(message: dict) -> str:
386
+ async def _file_write_approval_handler(message: dict) -> str:
368
387
  method = message.get("method")
369
388
  params = message.get("params")
370
389
  params = params if isinstance(params, dict) else {}
@@ -385,9 +404,11 @@ def _build_app_context(
385
404
  }
386
405
  )
387
406
  return "decline"
388
- rejected = [path for path in normalized if path not in allowed_doc_paths]
407
+ rejected = [
408
+ path for path in normalized if not _path_is_allowed_for_file_write(path)
409
+ ]
389
410
  if rejected:
390
- notice = "Rejected write to non-doc files: " + ", ".join(rejected)
411
+ notice = "Rejected write outside allowlist: " + ", ".join(rejected)
391
412
  await app_server_events.handle_notification(
392
413
  {
393
414
  "method": "error",
@@ -401,7 +422,7 @@ def _build_app_context(
401
422
  return "decline"
402
423
  return "accept"
403
424
  if method == "item/commandExecution/requestApproval":
404
- notice = "Rejected command execution in doc chat session."
425
+ notice = "Rejected command execution in file write session."
405
426
  await app_server_events.handle_notification(
406
427
  {
407
428
  "method": "error",
@@ -421,7 +442,7 @@ def _build_app_context(
421
442
  event_prefix="web.app_server",
422
443
  base_env=env,
423
444
  notification_handler=app_server_events.handle_notification,
424
- approval_handler=_doc_chat_approval_handler,
445
+ approval_handler=_file_write_approval_handler,
425
446
  )
426
447
  app_server_threads = AppServerThreadRegistry(
427
448
  default_app_server_threads_path(engine.repo_root)
@@ -441,27 +462,7 @@ def _build_app_context(
441
462
  logger=logger,
442
463
  env=env,
443
464
  subagent_models=subagent_models,
444
- )
445
- doc_chat = DocChatService(
446
- engine,
447
- app_server_supervisor=app_server_supervisor,
448
- app_server_threads=app_server_threads,
449
- app_server_events=app_server_events,
450
- opencode_supervisor=opencode_supervisor,
451
- env=env,
452
- )
453
- spec_ingest = SpecIngestService(
454
- engine,
455
- app_server_supervisor=app_server_supervisor,
456
- app_server_threads=app_server_threads,
457
- app_server_events=app_server_events,
458
- opencode_supervisor=opencode_supervisor,
459
- env=env,
460
- )
461
- snapshot_service = SnapshotService(
462
- engine,
463
- app_server_supervisor=app_server_supervisor,
464
- app_server_threads=app_server_threads,
465
+ session_stall_timeout_seconds=config.opencode.session_stall_timeout_seconds,
465
466
  )
466
467
  voice_service: Optional[VoiceService]
467
468
  if voice_missing_reason:
@@ -565,9 +566,6 @@ def _build_app_context(
565
566
  env=env,
566
567
  engine=engine,
567
568
  manager=manager,
568
- doc_chat=doc_chat,
569
- spec_ingest=spec_ingest,
570
- snapshot_service=snapshot_service,
571
569
  app_server_supervisor=app_server_supervisor,
572
570
  app_server_prune_interval=app_server_prune_interval,
573
571
  app_server_threads=app_server_threads,
@@ -600,9 +598,6 @@ def _apply_app_context(app: FastAPI, context: AppContext) -> None:
600
598
  app.state.engine = context.engine
601
599
  app.state.config = context.engine.config # Expose config consistently
602
600
  app.state.manager = context.manager
603
- app.state.doc_chat = context.doc_chat
604
- app.state.spec_ingest = context.spec_ingest
605
- app.state.snapshot_service = context.snapshot_service
606
601
  app.state.app_server_supervisor = context.app_server_supervisor
607
602
  app.state.app_server_prune_interval = context.app_server_prune_interval
608
603
  app.state.app_server_threads = context.app_server_threads
@@ -789,24 +784,6 @@ def _app_lifespan(context: AppContext):
789
784
 
790
785
  tasks.append(asyncio.create_task(_opencode_prune_loop()))
791
786
 
792
- pr_flow_manager = getattr(app.state, "pr_flow_manager", None)
793
- if pr_flow_manager is None:
794
- pr_flow_manager = PrFlowManager(
795
- app.state.engine.repo_root,
796
- app_server_supervisor=getattr(app.state, "app_server_supervisor", None),
797
- opencode_supervisor=getattr(app.state, "opencode_supervisor", None),
798
- logger=getattr(app.state, "logger", None),
799
- )
800
- app.state.pr_flow_manager = pr_flow_manager
801
- chatops = GitHubChatOpsPoller(
802
- app.state.engine.repo_root,
803
- pr_flow_manager,
804
- logger=getattr(app.state, "logger", None),
805
- )
806
- app.state.pr_flow_chatops = chatops
807
- if pr_flow_manager.chatops_config().get("enabled", False):
808
- tasks.append(asyncio.create_task(chatops.run()))
809
-
810
787
  if (
811
788
  context.tui_idle_seconds is not None
812
789
  and context.tui_idle_check_seconds is not None
@@ -879,17 +856,6 @@ def _app_lifespan(context: AppContext):
879
856
  task.cancel()
880
857
  if tasks:
881
858
  await asyncio.gather(*tasks, return_exceptions=True)
882
- chatops = getattr(app.state, "pr_flow_chatops", None)
883
- if chatops is not None:
884
- try:
885
- await chatops.stop()
886
- except Exception as exc:
887
- safe_log(
888
- app.state.logger,
889
- logging.DEBUG,
890
- "Failed to stop chatops during shutdown",
891
- exc=exc,
892
- )
893
859
  async with app.state.terminal_lock:
894
860
  for session in app.state.terminal_sessions.values():
895
861
  session.close()
@@ -930,14 +896,100 @@ def _app_lifespan(context: AppContext):
930
896
  return lifespan
931
897
 
932
898
 
899
+ def create_repo_app(
900
+ repo_root: Path,
901
+ server_overrides: Optional[ServerOverrides] = None,
902
+ hub_config: Optional[HubConfig] = None,
903
+ ) -> ASGIApp:
904
+ # Hub-only: repo apps are always mounted under `/repos/<id>` and must not
905
+ # apply their own base-path rewriting (the hub handles that globally).
906
+ context = _build_app_context(repo_root, base_path="", hub_config=hub_config)
907
+ app = FastAPI(redirect_slashes=False, lifespan=_app_lifespan(context))
908
+
909
+ class _RepoRootContextMiddleware(BaseHTTPMiddleware):
910
+ """Ensure find_repo_root() resolves to the mounted repo even when cwd differs."""
911
+
912
+ def __init__(self, app, repo_root: Path):
913
+ super().__init__(app)
914
+ self.repo_root = repo_root
915
+
916
+ async def dispatch(self, request, call_next):
917
+ token = set_repo_root_context(self.repo_root)
918
+ try:
919
+ return await call_next(request)
920
+ finally:
921
+ reset_repo_root_context(token)
922
+
923
+ app.add_middleware(_RepoRootContextMiddleware, repo_root=context.engine.repo_root)
924
+ _apply_app_context(app, context)
925
+ app.add_middleware(GZipMiddleware, minimum_size=500)
926
+ static_files = CacheStaticFiles(directory=context.static_dir)
927
+ app.state.static_files = static_files
928
+ app.state.static_assets_lock = threading.Lock()
929
+ app.state.hub_static_assets = (
930
+ hub_config.static_assets if hub_config is not None else None
931
+ )
932
+ app.mount("/static", static_files, name="static")
933
+ # Route handlers
934
+ app.include_router(build_repo_router(context.static_dir))
935
+
936
+ allowed_hosts = _resolve_allowed_hosts(
937
+ context.engine.config.server_host, context.engine.config.server_allowed_hosts
938
+ )
939
+ allowed_origins = context.engine.config.server_allowed_origins
940
+ auth_token_env = context.engine.config.server_auth_token_env
941
+ if server_overrides is not None:
942
+ if server_overrides.allowed_hosts is not None:
943
+ allowed_hosts = list(server_overrides.allowed_hosts)
944
+ if server_overrides.allowed_origins is not None:
945
+ allowed_origins = list(server_overrides.allowed_origins)
946
+ if server_overrides.auth_token_env is not None:
947
+ auth_token_env = server_overrides.auth_token_env
948
+ auth_token = _resolve_auth_token(auth_token_env, env=context.env)
949
+ app.state.auth_token = auth_token
950
+ if auth_token:
951
+ app.add_middleware(
952
+ AuthTokenMiddleware, auth_token=auth_token, base_path=context.base_path
953
+ )
954
+ app.add_middleware(
955
+ HostOriginMiddleware,
956
+ allowed_hosts=allowed_hosts,
957
+ allowed_origins=allowed_origins,
958
+ )
959
+ app.add_middleware(RequestIdMiddleware)
960
+ app.add_middleware(SecurityHeadersMiddleware)
961
+
962
+ return app
963
+
964
+
933
965
  def create_app(
934
966
  repo_root: Optional[Path] = None,
935
967
  base_path: Optional[str] = None,
936
968
  server_overrides: Optional[ServerOverrides] = None,
937
969
  hub_config: Optional[HubConfig] = None,
938
970
  ) -> ASGIApp:
971
+ """
972
+ Public-facing factory for standalone repo apps (non-hub) retained for backward compatibility.
973
+ """
974
+ # Respect provided base_path when running directly; hub passes base_path="".
939
975
  context = _build_app_context(repo_root, base_path, hub_config=hub_config)
940
976
  app = FastAPI(redirect_slashes=False, lifespan=_app_lifespan(context))
977
+
978
+ class _RepoRootContextMiddleware(BaseHTTPMiddleware):
979
+ """Ensure find_repo_root() resolves to the mounted repo even when cwd differs."""
980
+
981
+ def __init__(self, app, repo_root: Path):
982
+ super().__init__(app)
983
+ self.repo_root = repo_root
984
+
985
+ async def dispatch(self, request, call_next):
986
+ token = set_repo_root_context(self.repo_root)
987
+ try:
988
+ return await call_next(request)
989
+ finally:
990
+ reset_repo_root_context(token)
991
+
992
+ app.add_middleware(_RepoRootContextMiddleware, repo_root=context.engine.repo_root)
941
993
  _apply_app_context(app, context)
942
994
  app.add_middleware(GZipMiddleware, minimum_size=500)
943
995
  static_files = CacheStaticFiles(directory=context.static_dir)
@@ -964,16 +1016,21 @@ def create_app(
964
1016
  auth_token_env = server_overrides.auth_token_env
965
1017
  auth_token = _resolve_auth_token(auth_token_env, env=context.env)
966
1018
  app.state.auth_token = auth_token
967
- asgi_app: ASGIApp = app
968
1019
  if auth_token:
969
- asgi_app = AuthTokenMiddleware(asgi_app, auth_token, context.base_path)
1020
+ app.add_middleware(
1021
+ AuthTokenMiddleware, auth_token=auth_token, base_path=context.base_path
1022
+ )
970
1023
  if context.base_path:
971
- asgi_app = BasePathRouterMiddleware(asgi_app, context.base_path)
972
- asgi_app = HostOriginMiddleware(asgi_app, allowed_hosts, allowed_origins)
973
- asgi_app = RequestIdMiddleware(asgi_app)
974
- asgi_app = SecurityHeadersMiddleware(asgi_app)
1024
+ app.add_middleware(BasePathRouterMiddleware, base_path=context.base_path)
1025
+ app.add_middleware(
1026
+ HostOriginMiddleware,
1027
+ allowed_hosts=allowed_hosts,
1028
+ allowed_origins=allowed_origins,
1029
+ )
1030
+ app.add_middleware(RequestIdMiddleware)
1031
+ app.add_middleware(SecurityHeadersMiddleware)
975
1032
 
976
- return asgi_app
1033
+ return app
977
1034
 
978
1035
 
979
1036
  def create_hub_app(
@@ -1096,9 +1153,8 @@ def create_hub_app(
1096
1153
  return False
1097
1154
  try:
1098
1155
  # Hub already handles the base path; avoid reapplying it in child apps.
1099
- sub_app = create_app(
1156
+ sub_app = create_repo_app(
1100
1157
  repo_path,
1101
- base_path="",
1102
1158
  server_overrides=repo_server_overrides,
1103
1159
  hub_config=context.config,
1104
1160
  )
@@ -1126,6 +1182,9 @@ def create_hub_app(
1126
1182
  exc=exc2,
1127
1183
  )
1128
1184
  return False
1185
+ fastapi_app = _unwrap_fastapi(sub_app)
1186
+ if fastapi_app is not None:
1187
+ fastapi_app.state.repo_id = prefix
1129
1188
  app.mount(f"/repos/{prefix}", sub_app)
1130
1189
  mounted_repos.add(prefix)
1131
1190
  repo_apps[prefix] = sub_app
@@ -1154,9 +1213,8 @@ def create_hub_app(
1154
1213
  continue
1155
1214
  # Hub already handles the base path; avoid reapplying it in child apps.
1156
1215
  try:
1157
- sub_app = create_app(
1216
+ sub_app = create_repo_app(
1158
1217
  snap.path,
1159
- base_path="",
1160
1218
  server_overrides=repo_server_overrides,
1161
1219
  hub_config=context.config,
1162
1220
  )
@@ -1188,6 +1246,9 @@ def create_hub_app(
1188
1246
  exc=exc2,
1189
1247
  )
1190
1248
  continue
1249
+ fastapi_app = _unwrap_fastapi(sub_app)
1250
+ if fastapi_app is not None:
1251
+ fastapi_app.state.repo_id = snap.id
1191
1252
  app.mount(f"/repos/{snap.id}", sub_app)
1192
1253
  mounted_repos.add(snap.id)
1193
1254
  repo_apps[snap.id] = sub_app
@@ -1366,6 +1427,123 @@ def create_hub_app(
1366
1427
  **series,
1367
1428
  }
1368
1429
 
1430
+ @app.get("/hub/messages")
1431
+ async def hub_messages(limit: int = 100):
1432
+ """Return paused ticket_flow dispatches across all repos.
1433
+
1434
+ The hub inbox is intentionally simple: it surfaces the latest archived
1435
+ dispatch for each paused ticket_flow run.
1436
+ """
1437
+
1438
+ def _latest_dispatch(
1439
+ repo_root: Path, run_id: str, input_data: dict
1440
+ ) -> Optional[dict]:
1441
+ try:
1442
+ workspace_root = Path(input_data.get("workspace_root") or repo_root)
1443
+ runs_dir = Path(input_data.get("runs_dir") or ".codex-autorunner/runs")
1444
+ outbox_paths = resolve_outbox_paths(
1445
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
1446
+ )
1447
+ history_dir = outbox_paths.dispatch_history_dir
1448
+ if not history_dir.exists() or not history_dir.is_dir():
1449
+ return None
1450
+ seq_dirs: list[Path] = []
1451
+ for child in history_dir.iterdir():
1452
+ if not child.is_dir():
1453
+ continue
1454
+ name = child.name
1455
+ if len(name) == 4 and name.isdigit():
1456
+ seq_dirs.append(child)
1457
+ if not seq_dirs:
1458
+ return None
1459
+ latest_dir = sorted(seq_dirs, key=lambda p: p.name)[-1]
1460
+ seq = int(latest_dir.name)
1461
+ dispatch_path = latest_dir / "DISPATCH.md"
1462
+ dispatch, errors = parse_dispatch(dispatch_path)
1463
+ if errors or dispatch is None:
1464
+ return {
1465
+ "seq": seq,
1466
+ "dir": safe_relpath(latest_dir, repo_root),
1467
+ "dispatch": None,
1468
+ "errors": errors,
1469
+ "files": [],
1470
+ }
1471
+ files: list[str] = []
1472
+ for child in sorted(latest_dir.iterdir(), key=lambda p: p.name):
1473
+ if child.name.startswith("."):
1474
+ continue
1475
+ if child.name == "DISPATCH.md":
1476
+ continue
1477
+ if child.is_file():
1478
+ files.append(child.name)
1479
+ dispatch_dict = {
1480
+ "mode": dispatch.mode,
1481
+ "title": dispatch.title,
1482
+ "body": dispatch.body,
1483
+ "extra": dispatch.extra,
1484
+ "is_handoff": dispatch.is_handoff,
1485
+ }
1486
+ return {
1487
+ "seq": seq,
1488
+ "dir": safe_relpath(latest_dir, repo_root),
1489
+ "dispatch": dispatch_dict,
1490
+ "errors": [],
1491
+ "files": files,
1492
+ }
1493
+ except Exception:
1494
+ return None
1495
+
1496
+ def _gather() -> list[dict]:
1497
+ messages: list[dict] = []
1498
+ try:
1499
+ snapshots = context.supervisor.list_repos()
1500
+ except Exception:
1501
+ return []
1502
+ for snap in snapshots:
1503
+ if not (snap.initialized and snap.exists_on_disk):
1504
+ continue
1505
+ repo_root = snap.path
1506
+ db_path = repo_root / ".codex-autorunner" / "flows.db"
1507
+ if not db_path.exists():
1508
+ continue
1509
+ try:
1510
+ store = FlowStore(db_path)
1511
+ store.initialize()
1512
+ paused = store.list_flow_runs(
1513
+ flow_type="ticket_flow", status=FlowRunStatus.PAUSED
1514
+ )
1515
+ except Exception:
1516
+ continue
1517
+ if not paused:
1518
+ continue
1519
+ for record in paused:
1520
+ latest = _latest_dispatch(
1521
+ repo_root, str(record.id), dict(record.input_data or {})
1522
+ )
1523
+ if not latest or not latest.get("dispatch"):
1524
+ continue
1525
+ messages.append(
1526
+ {
1527
+ "repo_id": snap.id,
1528
+ "repo_display_name": snap.display_name,
1529
+ "repo_path": str(snap.path),
1530
+ "run_id": record.id,
1531
+ "run_created_at": record.created_at,
1532
+ "status": record.status.value,
1533
+ "seq": latest["seq"],
1534
+ "dispatch": latest["dispatch"],
1535
+ "files": latest.get("files") or [],
1536
+ "open_url": f"/repos/{snap.id}/?tab=inbox&run_id={record.id}",
1537
+ }
1538
+ )
1539
+ messages.sort(key=lambda m: (m.get("run_created_at") or ""), reverse=True)
1540
+ if limit and limit > 0:
1541
+ return messages[: int(limit)]
1542
+ return messages
1543
+
1544
+ items = await asyncio.to_thread(_gather)
1545
+ return {"items": items}
1546
+
1369
1547
  @app.get("/hub/repos")
1370
1548
  async def list_repos():
1371
1549
  safe_log(app.state.logger, logging.INFO, "Hub list_repos")
@@ -495,11 +495,10 @@ class RequestIdMiddleware:
495
495
  """Check if endpoint should log response size (docs, runs, hub repos)."""
496
496
  path_lower = path.lower()
497
497
  heavy_prefixes = (
498
- "/api/docs",
499
- "/api/snapshot",
500
- "/api/runs",
498
+ "/api/workspace",
499
+ "/api/workspace/spec/ingest",
500
+ "/api/file-chat",
501
501
  "/api/usage",
502
- "/api/ingest-spec",
503
502
  "/hub/usage",
504
503
  "/hub/repos",
505
504
  )