codex-autorunner 0.1.2__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 (276) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/__main__.py +4 -0
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/opencode/client.py +68 -35
  5. codex_autorunner/agents/opencode/constants.py +3 -0
  6. codex_autorunner/agents/opencode/harness.py +6 -1
  7. codex_autorunner/agents/opencode/logging.py +21 -5
  8. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  9. codex_autorunner/agents/opencode/runtime.py +176 -47
  10. codex_autorunner/agents/opencode/supervisor.py +36 -48
  11. codex_autorunner/agents/registry.py +155 -8
  12. codex_autorunner/api.py +25 -0
  13. codex_autorunner/bootstrap.py +22 -37
  14. codex_autorunner/cli.py +5 -1156
  15. codex_autorunner/codex_cli.py +20 -84
  16. codex_autorunner/core/__init__.py +4 -0
  17. codex_autorunner/core/about_car.py +49 -32
  18. codex_autorunner/core/adapter_utils.py +21 -0
  19. codex_autorunner/core/app_server_ids.py +59 -0
  20. codex_autorunner/core/app_server_logging.py +7 -3
  21. codex_autorunner/core/app_server_prompts.py +27 -260
  22. codex_autorunner/core/app_server_threads.py +26 -28
  23. codex_autorunner/core/app_server_utils.py +165 -0
  24. codex_autorunner/core/archive.py +349 -0
  25. codex_autorunner/core/codex_runner.py +12 -2
  26. codex_autorunner/core/config.py +587 -103
  27. codex_autorunner/core/docs.py +10 -2
  28. codex_autorunner/core/drafts.py +136 -0
  29. codex_autorunner/core/engine.py +1531 -866
  30. codex_autorunner/core/exceptions.py +4 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +202 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +88 -0
  35. codex_autorunner/core/flows/reasons.py +52 -0
  36. codex_autorunner/core/flows/reconciler.py +131 -0
  37. codex_autorunner/core/flows/runtime.py +382 -0
  38. codex_autorunner/core/flows/store.py +568 -0
  39. codex_autorunner/core/flows/transition.py +138 -0
  40. codex_autorunner/core/flows/ux_helpers.py +257 -0
  41. codex_autorunner/core/flows/worker_process.py +242 -0
  42. codex_autorunner/core/git_utils.py +62 -0
  43. codex_autorunner/core/hub.py +136 -16
  44. codex_autorunner/core/locks.py +4 -0
  45. codex_autorunner/core/notifications.py +14 -2
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/core/ports/agent_backend.py +150 -0
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/core/ports/run_event.py +91 -0
  50. codex_autorunner/core/prompt.py +15 -7
  51. codex_autorunner/core/redaction.py +29 -0
  52. codex_autorunner/core/review_context.py +5 -8
  53. codex_autorunner/core/run_index.py +6 -0
  54. codex_autorunner/core/runner_process.py +5 -2
  55. codex_autorunner/core/state.py +0 -88
  56. codex_autorunner/core/state_roots.py +57 -0
  57. codex_autorunner/core/supervisor_protocol.py +15 -0
  58. codex_autorunner/core/supervisor_utils.py +67 -0
  59. codex_autorunner/core/text_delta_coalescer.py +54 -0
  60. codex_autorunner/core/ticket_linter_cli.py +201 -0
  61. codex_autorunner/core/ticket_manager_cli.py +432 -0
  62. codex_autorunner/core/update.py +24 -16
  63. codex_autorunner/core/update_paths.py +28 -0
  64. codex_autorunner/core/update_runner.py +2 -0
  65. codex_autorunner/core/usage.py +164 -12
  66. codex_autorunner/core/utils.py +120 -11
  67. codex_autorunner/discovery.py +2 -4
  68. codex_autorunner/flows/review/__init__.py +17 -0
  69. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  70. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  71. codex_autorunner/flows/ticket_flow/definition.py +98 -0
  72. codex_autorunner/integrations/agents/__init__.py +17 -0
  73. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  74. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  75. codex_autorunner/integrations/agents/codex_backend.py +448 -0
  76. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  77. codex_autorunner/integrations/agents/opencode_backend.py +598 -0
  78. codex_autorunner/integrations/agents/runner.py +91 -0
  79. codex_autorunner/integrations/agents/wiring.py +271 -0
  80. codex_autorunner/integrations/app_server/client.py +583 -152
  81. codex_autorunner/integrations/app_server/env.py +2 -107
  82. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  83. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  84. codex_autorunner/integrations/telegram/adapter.py +204 -165
  85. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  86. codex_autorunner/integrations/telegram/config.py +221 -0
  87. codex_autorunner/integrations/telegram/constants.py +17 -2
  88. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  89. codex_autorunner/integrations/telegram/doctor.py +47 -0
  90. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
  91. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  92. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  93. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  94. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
  95. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  96. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  97. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  98. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
  99. codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
  100. codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
  101. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  102. codex_autorunner/integrations/telegram/helpers.py +111 -16
  103. codex_autorunner/integrations/telegram/outbox.py +208 -37
  104. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  105. codex_autorunner/integrations/telegram/service.py +221 -42
  106. codex_autorunner/integrations/telegram/state.py +100 -2
  107. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
  108. codex_autorunner/integrations/telegram/transport.py +39 -4
  109. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  110. codex_autorunner/manifest.py +2 -0
  111. codex_autorunner/plugin_api.py +22 -0
  112. codex_autorunner/routes/__init__.py +37 -67
  113. codex_autorunner/routes/agents.py +2 -137
  114. codex_autorunner/routes/analytics.py +3 -0
  115. codex_autorunner/routes/app_server.py +2 -131
  116. codex_autorunner/routes/base.py +2 -624
  117. codex_autorunner/routes/file_chat.py +7 -0
  118. codex_autorunner/routes/flows.py +7 -0
  119. codex_autorunner/routes/messages.py +7 -0
  120. codex_autorunner/routes/repos.py +2 -196
  121. codex_autorunner/routes/review.py +2 -147
  122. codex_autorunner/routes/sessions.py +2 -175
  123. codex_autorunner/routes/settings.py +2 -168
  124. codex_autorunner/routes/shared.py +2 -275
  125. codex_autorunner/routes/system.py +4 -188
  126. codex_autorunner/routes/usage.py +3 -0
  127. codex_autorunner/routes/voice.py +2 -119
  128. codex_autorunner/routes/workspace.py +3 -0
  129. codex_autorunner/server.py +3 -2
  130. codex_autorunner/static/agentControls.js +41 -11
  131. codex_autorunner/static/agentEvents.js +248 -0
  132. codex_autorunner/static/app.js +35 -24
  133. codex_autorunner/static/archive.js +826 -0
  134. codex_autorunner/static/archiveApi.js +37 -0
  135. codex_autorunner/static/autoRefresh.js +36 -8
  136. codex_autorunner/static/bootstrap.js +1 -0
  137. codex_autorunner/static/bus.js +1 -0
  138. codex_autorunner/static/cache.js +1 -0
  139. codex_autorunner/static/constants.js +20 -4
  140. codex_autorunner/static/dashboard.js +344 -325
  141. codex_autorunner/static/diffRenderer.js +37 -0
  142. codex_autorunner/static/docChatCore.js +324 -0
  143. codex_autorunner/static/docChatStorage.js +65 -0
  144. codex_autorunner/static/docChatVoice.js +65 -0
  145. codex_autorunner/static/docEditor.js +133 -0
  146. codex_autorunner/static/env.js +1 -0
  147. codex_autorunner/static/eventSummarizer.js +166 -0
  148. codex_autorunner/static/fileChat.js +182 -0
  149. codex_autorunner/static/health.js +155 -0
  150. codex_autorunner/static/hub.js +126 -185
  151. codex_autorunner/static/index.html +839 -863
  152. codex_autorunner/static/liveUpdates.js +1 -0
  153. codex_autorunner/static/loader.js +1 -0
  154. codex_autorunner/static/messages.js +873 -0
  155. codex_autorunner/static/mobileCompact.js +2 -1
  156. codex_autorunner/static/preserve.js +17 -0
  157. codex_autorunner/static/settings.js +149 -217
  158. codex_autorunner/static/smartRefresh.js +52 -0
  159. codex_autorunner/static/styles.css +8850 -3876
  160. codex_autorunner/static/tabs.js +175 -11
  161. codex_autorunner/static/terminal.js +32 -0
  162. codex_autorunner/static/terminalManager.js +34 -59
  163. codex_autorunner/static/ticketChatActions.js +333 -0
  164. codex_autorunner/static/ticketChatEvents.js +16 -0
  165. codex_autorunner/static/ticketChatStorage.js +16 -0
  166. codex_autorunner/static/ticketChatStream.js +264 -0
  167. codex_autorunner/static/ticketEditor.js +844 -0
  168. codex_autorunner/static/ticketVoice.js +9 -0
  169. codex_autorunner/static/tickets.js +1988 -0
  170. codex_autorunner/static/utils.js +43 -3
  171. codex_autorunner/static/voice.js +1 -0
  172. codex_autorunner/static/workspace.js +765 -0
  173. codex_autorunner/static/workspaceApi.js +53 -0
  174. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  175. codex_autorunner/surfaces/__init__.py +5 -0
  176. codex_autorunner/surfaces/cli/__init__.py +6 -0
  177. codex_autorunner/surfaces/cli/cli.py +1224 -0
  178. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  179. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  180. codex_autorunner/surfaces/web/__init__.py +1 -0
  181. codex_autorunner/surfaces/web/app.py +2019 -0
  182. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  183. codex_autorunner/surfaces/web/middleware.py +587 -0
  184. codex_autorunner/surfaces/web/pty_session.py +370 -0
  185. codex_autorunner/surfaces/web/review.py +6 -0
  186. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  187. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  188. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  189. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  190. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  191. codex_autorunner/surfaces/web/routes/base.py +615 -0
  192. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  193. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  194. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  195. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  196. codex_autorunner/surfaces/web/routes/review.py +148 -0
  197. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  198. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  199. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  200. codex_autorunner/surfaces/web/routes/system.py +196 -0
  201. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  202. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  203. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  204. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  205. codex_autorunner/surfaces/web/schemas.py +417 -0
  206. codex_autorunner/surfaces/web/static_assets.py +490 -0
  207. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  208. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  209. codex_autorunner/tickets/__init__.py +27 -0
  210. codex_autorunner/tickets/agent_pool.py +399 -0
  211. codex_autorunner/tickets/files.py +89 -0
  212. codex_autorunner/tickets/frontmatter.py +55 -0
  213. codex_autorunner/tickets/lint.py +102 -0
  214. codex_autorunner/tickets/models.py +97 -0
  215. codex_autorunner/tickets/outbox.py +244 -0
  216. codex_autorunner/tickets/replies.py +179 -0
  217. codex_autorunner/tickets/runner.py +881 -0
  218. codex_autorunner/tickets/spec_ingest.py +77 -0
  219. codex_autorunner/web/__init__.py +5 -1
  220. codex_autorunner/web/app.py +2 -1771
  221. codex_autorunner/web/hub_jobs.py +2 -191
  222. codex_autorunner/web/middleware.py +2 -587
  223. codex_autorunner/web/pty_session.py +2 -369
  224. codex_autorunner/web/runner_manager.py +2 -24
  225. codex_autorunner/web/schemas.py +2 -396
  226. codex_autorunner/web/static_assets.py +4 -484
  227. codex_autorunner/web/static_refresh.py +2 -85
  228. codex_autorunner/web/terminal_sessions.py +2 -77
  229. codex_autorunner/workspace/__init__.py +40 -0
  230. codex_autorunner/workspace/paths.py +335 -0
  231. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  232. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  233. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
  234. codex_autorunner/agents/execution/policy.py +0 -292
  235. codex_autorunner/agents/factory.py +0 -52
  236. codex_autorunner/agents/orchestrator.py +0 -358
  237. codex_autorunner/core/doc_chat.py +0 -1446
  238. codex_autorunner/core/snapshot.py +0 -580
  239. codex_autorunner/integrations/github/chatops.py +0 -268
  240. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  241. codex_autorunner/routes/docs.py +0 -381
  242. codex_autorunner/routes/github.py +0 -327
  243. codex_autorunner/routes/runs.py +0 -250
  244. codex_autorunner/spec_ingest.py +0 -812
  245. codex_autorunner/static/docChatActions.js +0 -287
  246. codex_autorunner/static/docChatEvents.js +0 -300
  247. codex_autorunner/static/docChatRender.js +0 -205
  248. codex_autorunner/static/docChatStream.js +0 -361
  249. codex_autorunner/static/docs.js +0 -20
  250. codex_autorunner/static/docsClipboard.js +0 -69
  251. codex_autorunner/static/docsCrud.js +0 -257
  252. codex_autorunner/static/docsDocUpdates.js +0 -62
  253. codex_autorunner/static/docsDrafts.js +0 -16
  254. codex_autorunner/static/docsElements.js +0 -69
  255. codex_autorunner/static/docsInit.js +0 -285
  256. codex_autorunner/static/docsParse.js +0 -160
  257. codex_autorunner/static/docsSnapshot.js +0 -87
  258. codex_autorunner/static/docsSpecIngest.js +0 -263
  259. codex_autorunner/static/docsState.js +0 -127
  260. codex_autorunner/static/docsThreadRegistry.js +0 -44
  261. codex_autorunner/static/docsUi.js +0 -153
  262. codex_autorunner/static/docsVoice.js +0 -56
  263. codex_autorunner/static/github.js +0 -504
  264. codex_autorunner/static/logs.js +0 -678
  265. codex_autorunner/static/review.js +0 -157
  266. codex_autorunner/static/runs.js +0 -418
  267. codex_autorunner/static/snapshot.js +0 -124
  268. codex_autorunner/static/state.js +0 -94
  269. codex_autorunner/static/todoPreview.js +0 -27
  270. codex_autorunner/workspace.py +0 -16
  271. codex_autorunner-0.1.2.dist-info/METADATA +0 -249
  272. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  273. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  274. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  275. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  276. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,615 @@
1
+ """
2
+ Base routes: Index, WebSocket terminal.
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import time
9
+ import uuid
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect
14
+ from fastapi.responses import (
15
+ HTMLResponse,
16
+ JSONResponse,
17
+ )
18
+
19
+ from ....core.config import HubConfig
20
+ from ....core.logging_utils import safe_log
21
+ from ....core.state import SessionRecord, now_iso, persist_session_registry
22
+ from ..pty_session import REPLAY_END, ActiveSession, PTYSession
23
+ from ..schemas import VersionResponse
24
+ from ..static_assets import index_response_headers, render_index_html
25
+ from ..static_refresh import refresh_static_assets
26
+ from .shared import (
27
+ build_codex_terminal_cmd,
28
+ build_opencode_terminal_cmd,
29
+ )
30
+
31
+ ALT_SCREEN_ENTER = b"\x1b[?1049h"
32
+
33
+
34
+ def _get_pty_session_cls() -> type:
35
+ """
36
+ Prefer legacy shim PTYSession if monkeypatched via codex_autorunner.routes.base.
37
+
38
+ The surface refactor moved PTYSession into the web package, but tests still
39
+ patch the old path. Look for that module at call time so the patch applies.
40
+ """
41
+ import sys
42
+
43
+ legacy_module = sys.modules.get("codex_autorunner.routes.base")
44
+ if legacy_module is not None:
45
+ patched = getattr(legacy_module, "PTYSession", None)
46
+ if patched is not None:
47
+ return patched
48
+ return PTYSession
49
+
50
+
51
+ def _serve_index(request: Request, static_dir: Path):
52
+ active_static = getattr(request.app.state, "static_dir", static_dir)
53
+ index_path = active_static / "index.html"
54
+ if not index_path.exists():
55
+ if refresh_static_assets(request.app):
56
+ active_static = request.app.state.static_dir
57
+ index_path = active_static / "index.html"
58
+ if not index_path.exists():
59
+ raise HTTPException(
60
+ status_code=500, detail="Static UI assets missing; reinstall package"
61
+ )
62
+ html = render_index_html(active_static, request.app.state.asset_version)
63
+ return HTMLResponse(html, headers=index_response_headers())
64
+
65
+
66
+ def build_base_routes(static_dir: Path) -> APIRouter:
67
+ """Build routes for index, state, logs, and terminal WebSocket."""
68
+ router = APIRouter()
69
+
70
+ @router.get("/", include_in_schema=False)
71
+ def index(request: Request):
72
+ return _serve_index(request, static_dir)
73
+
74
+ @router.get("/api/version", response_model=VersionResponse)
75
+ def get_version(request: Request):
76
+ return {"asset_version": request.app.state.asset_version}
77
+
78
+ @router.get("/api/repo/health")
79
+ def repo_health(request: Request):
80
+ config = getattr(request.app.state, "config", None)
81
+ if isinstance(config, HubConfig):
82
+ raise HTTPException(
83
+ status_code=404, detail="Repo health not available in hub mode"
84
+ )
85
+
86
+ engine = getattr(request.app.state, "engine", None)
87
+ repo_root = getattr(engine, "repo_root", None)
88
+ if repo_root is None:
89
+ return JSONResponse(
90
+ {"status": "error", "detail": "Repo context unavailable"},
91
+ status_code=503,
92
+ )
93
+
94
+ flows_db = repo_root / ".codex-autorunner" / "flows.db"
95
+
96
+ docs_dir = repo_root / ".codex-autorunner"
97
+ docs_status = "ok" if docs_dir.exists() else "missing"
98
+
99
+ tickets_dir = repo_root / ".codex-autorunner" / "tickets"
100
+ tickets_status = "ok" if tickets_dir.exists() else "missing"
101
+
102
+ flows_status = "ok" if tickets_dir.exists() else "missing"
103
+ flows_detail = None
104
+
105
+ overall_status = (
106
+ "ok" if docs_status == "ok" and tickets_status == "ok" else "degraded"
107
+ )
108
+
109
+ return {
110
+ "status": overall_status,
111
+ "mode": "repo",
112
+ "repo_root": str(repo_root),
113
+ "flows": {
114
+ "status": flows_status,
115
+ "path": str(flows_db),
116
+ "detail": flows_detail,
117
+ },
118
+ "docs": {"status": docs_status, "path": str(docs_dir)},
119
+ "tickets": {"status": tickets_status, "path": str(tickets_dir)},
120
+ }
121
+
122
+ @router.websocket("/api/terminal")
123
+ async def terminal(ws: WebSocket):
124
+ selected_protocol = None
125
+ protocol_header = ws.headers.get("sec-websocket-protocol")
126
+ if protocol_header:
127
+ for entry in protocol_header.split(","):
128
+ candidate = entry.strip()
129
+ if not candidate:
130
+ continue
131
+ if candidate == "car-token":
132
+ selected_protocol = candidate
133
+ break
134
+ if candidate.startswith("car-token-b64."):
135
+ selected_protocol = candidate
136
+ break
137
+ if candidate.startswith("car-token."):
138
+ selected_protocol = candidate
139
+ break
140
+ if selected_protocol:
141
+ await ws.accept(subprotocol=selected_protocol)
142
+ else:
143
+ await ws.accept()
144
+ app = ws.scope.get("app")
145
+ if app is None:
146
+ await ws.close()
147
+ return
148
+ # Track websocket for graceful shutdown during reload
149
+ active_websockets = getattr(app.state, "active_websockets", None)
150
+ if active_websockets is not None:
151
+ active_websockets.add(ws)
152
+ logger = app.state.logger
153
+ engine = app.state.engine
154
+ terminal_sessions: dict[str, ActiveSession] = app.state.terminal_sessions
155
+ terminal_lock: asyncio.Lock = app.state.terminal_lock
156
+ session_registry: dict[str, SessionRecord] = app.state.session_registry
157
+ repo_to_session: dict[str, str] = app.state.repo_to_session
158
+ session_env = getattr(app.state, "env", None)
159
+ repo_path = str(engine.repo_root)
160
+ state_path = engine.state_path
161
+ agent = (ws.query_params.get("agent") or "codex").strip().lower()
162
+ model = (ws.query_params.get("model") or "").strip() or None
163
+ reasoning = (ws.query_params.get("reasoning") or "").strip() or None
164
+ session_id = None
165
+ active_session: Optional[ActiveSession] = None
166
+ seen_update_interval = 5.0
167
+
168
+ ws_input_bytes_total = 0
169
+ ws_input_message_count = 0
170
+ ws_rate_limit_window_start = time.monotonic()
171
+ MAX_BYTES_PER_CONNECTION = 10 * 1024 * 1024
172
+ MAX_MESSAGES_PER_WINDOW = 1000
173
+ RATE_LIMIT_WINDOW_SECONDS = 60.0
174
+
175
+ def _session_key(repo: str, agent: str) -> str:
176
+ normalized = (agent or "").strip().lower()
177
+ # Backwards-compatible keying:
178
+ # - Codex sessions continue to use the bare repo path key so existing
179
+ # CLI/API callers that only know `repo_path` keep working.
180
+ # - Other agents (e.g. OpenCode) use a scoped `repo:agent` key.
181
+ if not normalized or normalized == "codex":
182
+ return repo
183
+ return f"{repo}:{normalized}"
184
+
185
+ client_session_id = ws.query_params.get("session_id")
186
+ close_session_id = ws.query_params.get("close_session_id")
187
+ mode = (ws.query_params.get("mode") or "").strip().lower()
188
+ attach_only = mode == "attach"
189
+ terminal_debug_param = (ws.query_params.get("terminal_debug") or "").strip()
190
+ terminal_debug = terminal_debug_param.lower() in {"1", "true", "yes", "on"}
191
+
192
+ def _mark_dirty() -> None:
193
+ app.state.session_state_dirty = True
194
+
195
+ def _maybe_persist_sessions(force: bool = False) -> None:
196
+ now = time.time()
197
+ last_write = app.state.session_state_last_write
198
+ if not force and not app.state.session_state_dirty:
199
+ return
200
+ if not force and now - last_write < seen_update_interval:
201
+ return
202
+ persist_session_registry(state_path, session_registry, repo_to_session)
203
+ app.state.session_state_last_write = now
204
+ app.state.session_state_dirty = False
205
+
206
+ def _touch_session(session_id: str) -> None:
207
+ record = session_registry.get(session_id)
208
+ if not record:
209
+ return
210
+ record.last_seen_at = now_iso()
211
+ if record.status != "active":
212
+ record.status = "active"
213
+ _mark_dirty()
214
+ _maybe_persist_sessions()
215
+
216
+ async with terminal_lock:
217
+ if client_session_id and client_session_id in terminal_sessions:
218
+ active_session = terminal_sessions[client_session_id]
219
+ if not active_session.pty.isalive():
220
+ active_session.close()
221
+ terminal_sessions.pop(client_session_id, None)
222
+ session_registry.pop(client_session_id, None)
223
+ repo_to_session = {
224
+ _session_key(repo, ag): sid
225
+ for repo, ag, sid in [
226
+ (
227
+ k.split(":", 1)[0],
228
+ (
229
+ (k.split(":", 1)[1] or "codex")
230
+ if ":" in k
231
+ else "codex"
232
+ ),
233
+ v,
234
+ )
235
+ for k, v in repo_to_session.items()
236
+ ]
237
+ if sid != client_session_id
238
+ }
239
+ app.state.repo_to_session = repo_to_session
240
+ active_session = None
241
+ _mark_dirty()
242
+ else:
243
+ session_id = client_session_id
244
+
245
+ if not active_session:
246
+ mapped_session_id = repo_to_session.get(_session_key(repo_path, agent))
247
+ if mapped_session_id:
248
+ mapped_session = terminal_sessions.get(mapped_session_id)
249
+ if mapped_session and mapped_session.pty.isalive():
250
+ active_session = mapped_session
251
+ session_id = mapped_session_id
252
+ else:
253
+ if mapped_session:
254
+ mapped_session.close()
255
+ terminal_sessions.pop(mapped_session_id, None)
256
+ session_registry.pop(mapped_session_id, None)
257
+ repo_to_session.pop(_session_key(repo_path, agent), None)
258
+ _mark_dirty()
259
+ if attach_only:
260
+ await ws.send_text(
261
+ json.dumps(
262
+ {
263
+ "type": "error",
264
+ "message": "Session not found",
265
+ "session_id": client_session_id,
266
+ }
267
+ )
268
+ )
269
+ await ws.close()
270
+ return
271
+ if (
272
+ close_session_id
273
+ and close_session_id in terminal_sessions
274
+ and close_session_id != client_session_id
275
+ ):
276
+ try:
277
+ session_to_close = terminal_sessions[close_session_id]
278
+ session_to_close.close()
279
+ await session_to_close.wait_closed()
280
+ finally:
281
+ terminal_sessions.pop(close_session_id, None)
282
+ session_registry.pop(close_session_id, None)
283
+ repo_to_session = {
284
+ _session_key(repo, ag): sid
285
+ for repo, ag, sid in [
286
+ (
287
+ k.split(":", 1)[0],
288
+ (
289
+ (k.split(":", 1)[1] or "codex")
290
+ if ":" in k
291
+ else "codex"
292
+ ),
293
+ v,
294
+ )
295
+ for k, v in repo_to_session.items()
296
+ ]
297
+ if sid != close_session_id
298
+ }
299
+ app.state.repo_to_session = repo_to_session
300
+ _mark_dirty()
301
+ session_id = str(uuid.uuid4())
302
+ resume_mode = mode == "resume"
303
+ if agent == "opencode":
304
+ cmd = build_opencode_terminal_cmd(
305
+ engine.config.agent_binary("opencode"),
306
+ model,
307
+ )
308
+ else:
309
+ cmd = build_codex_terminal_cmd(
310
+ engine,
311
+ resume_mode=resume_mode,
312
+ model=model,
313
+ reasoning=reasoning,
314
+ )
315
+ try:
316
+ pty_cls = _get_pty_session_cls()
317
+ pty = pty_cls(cmd, cwd=str(engine.repo_root), env=session_env)
318
+ active_session = ActiveSession(
319
+ session_id, pty, asyncio.get_running_loop()
320
+ )
321
+ terminal_sessions[session_id] = active_session
322
+ session_registry[session_id] = SessionRecord(
323
+ repo_path=repo_path,
324
+ created_at=now_iso(),
325
+ last_seen_at=now_iso(),
326
+ status="active",
327
+ agent=agent,
328
+ )
329
+ repo_to_session[_session_key(repo_path, agent)] = session_id
330
+ _mark_dirty()
331
+ except FileNotFoundError:
332
+ binary = cmd[0] if cmd else "codex"
333
+ await ws.send_text(
334
+ json.dumps(
335
+ {
336
+ "type": "error",
337
+ "message": f"Agent binary not found: {binary}",
338
+ }
339
+ )
340
+ )
341
+ await ws.close()
342
+ return
343
+ if active_session:
344
+ if session_id and session_id not in session_registry:
345
+ session_registry[session_id] = SessionRecord(
346
+ repo_path=repo_path,
347
+ created_at=now_iso(),
348
+ last_seen_at=now_iso(),
349
+ status="active",
350
+ agent=agent,
351
+ )
352
+ _mark_dirty()
353
+ if (
354
+ session_id
355
+ and repo_to_session.get(_session_key(repo_path, agent))
356
+ != session_id
357
+ ):
358
+ repo_to_session[_session_key(repo_path, agent)] = session_id
359
+ _mark_dirty()
360
+ _maybe_persist_sessions(force=True)
361
+
362
+ if attach_only and active_session:
363
+ active_session.refresh_alt_screen_state()
364
+ await ws.send_text(json.dumps({"type": "hello", "session_id": session_id}))
365
+ if attach_only and active_session and active_session.alt_screen_active:
366
+ await ws.send_bytes(ALT_SCREEN_ENTER)
367
+ if terminal_debug and active_session:
368
+ buffer_bytes, buffer_chunks = active_session.get_buffer_stats()
369
+ safe_log(
370
+ logger,
371
+ logging.INFO,
372
+ (
373
+ "Terminal connect debug: mode="
374
+ f"{mode} session={session_id} attach={attach_only} "
375
+ f"alt_screen={active_session.alt_screen_active} "
376
+ f"buffer_bytes={buffer_bytes} buffer_chunks={buffer_chunks}"
377
+ ),
378
+ )
379
+ include_replay_end = attach_only or mode == "resume" or bool(client_session_id)
380
+ if active_session is None:
381
+ await ws.close()
382
+ return
383
+ queue = active_session.add_subscriber(include_replay_end=include_replay_end)
384
+
385
+ async def pty_to_ws():
386
+ try:
387
+ while True:
388
+ data = await queue.get()
389
+ if data is REPLAY_END:
390
+ await ws.send_text(json.dumps({"type": "replay_end"}))
391
+ continue
392
+ if data is None:
393
+ if active_session:
394
+ exit_code = active_session.pty.exit_code()
395
+ if session_id:
396
+ async with terminal_lock:
397
+ record = session_registry.get(session_id)
398
+ if record:
399
+ record.status = "closed"
400
+ record.last_seen_at = now_iso()
401
+ _mark_dirty()
402
+ notifier = getattr(engine, "notifier", None)
403
+ if notifier:
404
+ asyncio.create_task(
405
+ notifier.notify_tui_session_finished_async(
406
+ session_id=session_id,
407
+ exit_code=exit_code,
408
+ repo_path=repo_path,
409
+ )
410
+ )
411
+ await ws.send_text(
412
+ json.dumps(
413
+ {
414
+ "type": "exit",
415
+ "code": exit_code,
416
+ "session_id": session_id,
417
+ }
418
+ )
419
+ )
420
+ break
421
+ await ws.send_bytes(data)
422
+ if session_id:
423
+ _touch_session(session_id)
424
+ except Exception:
425
+ safe_log(logger, logging.WARNING, "Terminal PTY to WS bridge failed")
426
+
427
+ async def ws_to_pty():
428
+ nonlocal ws_input_bytes_total, ws_input_message_count, ws_rate_limit_window_start
429
+ try:
430
+ while True:
431
+ msg = await ws.receive()
432
+ if msg["type"] == "websocket.disconnect":
433
+ break
434
+ if msg.get("bytes") is not None:
435
+ ws_input_message_count += 1
436
+ ws_input_bytes_total += len(msg["bytes"])
437
+ if (
438
+ ws_input_bytes_total > MAX_BYTES_PER_CONNECTION
439
+ or ws_input_message_count > MAX_MESSAGES_PER_WINDOW
440
+ ):
441
+ await ws.close(code=1008, reason="Rate limit exceeded")
442
+ return
443
+ now = time.monotonic()
444
+ if now - ws_rate_limit_window_start > RATE_LIMIT_WINDOW_SECONDS:
445
+ ws_input_bytes_total = 0
446
+ ws_input_message_count = 0
447
+ ws_rate_limit_window_start = now
448
+ # Queue input so PTY writes never block the event loop.
449
+ active_session.write_input(msg["bytes"])
450
+ active_session.mark_input_activity()
451
+ if session_id:
452
+ _touch_session(session_id)
453
+ continue
454
+ text = msg.get("text")
455
+ if not text:
456
+ continue
457
+ try:
458
+ payload = json.loads(text)
459
+ except json.JSONDecodeError:
460
+ continue
461
+ if payload.get("type") == "resize":
462
+ cols = int(payload.get("cols", 0))
463
+ rows = int(payload.get("rows", 0))
464
+ if cols > 0 and rows > 0:
465
+ active_session.pty.resize(cols, rows)
466
+ elif payload.get("type") == "input":
467
+ input_id = payload.get("id")
468
+ data = payload.get("data")
469
+ if not input_id or not isinstance(input_id, str):
470
+ await ws.send_text(
471
+ json.dumps(
472
+ {
473
+ "type": "error",
474
+ "message": "invalid input id",
475
+ }
476
+ )
477
+ )
478
+ continue
479
+ if data is None or not isinstance(data, str):
480
+ await ws.send_text(
481
+ json.dumps(
482
+ {
483
+ "type": "ack",
484
+ "id": input_id,
485
+ "ok": False,
486
+ "message": "invalid input data",
487
+ }
488
+ )
489
+ )
490
+ continue
491
+ encoded = data.encode("utf-8", errors="replace")
492
+ if len(encoded) > 1024 * 1024:
493
+ await ws.send_text(
494
+ json.dumps(
495
+ {
496
+ "type": "ack",
497
+ "id": input_id,
498
+ "ok": False,
499
+ "message": "input too large",
500
+ }
501
+ )
502
+ )
503
+ continue
504
+ ws_input_message_count += 1
505
+ ws_input_bytes_total += len(encoded)
506
+ if (
507
+ ws_input_bytes_total > MAX_BYTES_PER_CONNECTION
508
+ or ws_input_message_count > MAX_MESSAGES_PER_WINDOW
509
+ ):
510
+ await ws.close(code=1008, reason="Rate limit exceeded")
511
+ return
512
+ now = time.monotonic()
513
+ if now - ws_rate_limit_window_start > RATE_LIMIT_WINDOW_SECONDS:
514
+ ws_input_bytes_total = 0
515
+ ws_input_message_count = 0
516
+ ws_rate_limit_window_start = now
517
+ if active_session.mark_input_id_seen(input_id):
518
+ active_session.write_input(encoded)
519
+ active_session.mark_input_activity()
520
+ await ws.send_text(
521
+ json.dumps({"type": "ack", "id": input_id, "ok": True})
522
+ )
523
+ if session_id:
524
+ _touch_session(session_id)
525
+ elif payload.get("type") == "ping":
526
+ ws_input_message_count += 1
527
+ if ws_input_message_count > MAX_MESSAGES_PER_WINDOW:
528
+ await ws.close(code=1008, reason="Rate limit exceeded")
529
+ return
530
+ now = time.monotonic()
531
+ if now - ws_rate_limit_window_start > RATE_LIMIT_WINDOW_SECONDS:
532
+ ws_input_message_count = 0
533
+ ws_rate_limit_window_start = now
534
+ await ws.send_text(json.dumps({"type": "pong"}))
535
+ if session_id:
536
+ _touch_session(session_id)
537
+ except WebSocketDisconnect:
538
+ pass
539
+ except Exception:
540
+ safe_log(logger, logging.WARNING, "Terminal WS to PTY bridge failed")
541
+
542
+ forward_task = asyncio.create_task(pty_to_ws())
543
+ input_task = asyncio.create_task(ws_to_pty())
544
+ try:
545
+ done, pending = await asyncio.wait(
546
+ [forward_task, input_task], return_when=asyncio.FIRST_COMPLETED
547
+ )
548
+ for task in done:
549
+ try:
550
+ task.result()
551
+ except Exception:
552
+ safe_log(logger, logging.WARNING, "Terminal websocket task failed")
553
+ finally:
554
+ forward_task.cancel()
555
+ input_task.cancel()
556
+
557
+ if active_session:
558
+ active_session.remove_subscriber(queue)
559
+ if not active_session.pty.isalive():
560
+ async with terminal_lock:
561
+ if session_id:
562
+ terminal_sessions.pop(session_id, None)
563
+ session_registry.pop(session_id, None)
564
+ repo_to_session = {
565
+ _session_key(repo, ag): sid
566
+ for repo, ag, sid in [
567
+ (
568
+ k.split(":", 1)[0],
569
+ (
570
+ (k.split(":", 1)[1] or "codex")
571
+ if ":" in k
572
+ else "codex"
573
+ ),
574
+ v,
575
+ )
576
+ for k, v in repo_to_session.items()
577
+ ]
578
+ if sid != session_id
579
+ }
580
+ app.state.repo_to_session = repo_to_session
581
+ _mark_dirty()
582
+ if session_id:
583
+ _touch_session(session_id)
584
+ _maybe_persist_sessions(force=True)
585
+
586
+ try:
587
+ await ws.close()
588
+ except Exception:
589
+ safe_log(logger, logging.WARNING, "Terminal websocket close failed")
590
+ finally:
591
+ # Unregister websocket from active set
592
+ if active_websockets is not None:
593
+ active_websockets.discard(ws)
594
+
595
+ return router
596
+
597
+
598
+ def build_frontend_routes(static_dir: Path) -> APIRouter:
599
+ """Build catch-all routes for frontend tabs."""
600
+ router = APIRouter()
601
+
602
+ @router.get("/{tab}", include_in_schema=False)
603
+ def tab_route(tab: str, request: Request):
604
+ if tab in {
605
+ "workspace",
606
+ "tickets",
607
+ "messages",
608
+ "analytics",
609
+ "terminal",
610
+ "settings",
611
+ }:
612
+ return _serve_index(request, static_dir)
613
+ raise HTTPException(status_code=404, detail="Not Found")
614
+
615
+ return router