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