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
@@ -1,625 +1,3 @@
1
- """
2
- Base routes: Index, state streaming, WebSocket terminal, and logs.
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
- PlainTextResponse,
18
- StreamingResponse,
19
- )
20
-
21
- from ..codex_cli import extract_flag_value
22
- from ..core.logging_utils import safe_log
23
- from ..core.state import SessionRecord, load_state, now_iso, persist_session_registry
24
- from ..web.pty_session import REPLAY_END, ActiveSession, PTYSession
25
- from ..web.schemas import StateResponse, VersionResponse
26
- from ..web.static_assets import index_response_headers, render_index_html
27
- from ..web.static_refresh import refresh_static_assets
28
- from .shared import (
29
- SSE_HEADERS,
30
- build_codex_terminal_cmd,
31
- build_opencode_terminal_cmd,
32
- log_stream,
33
- resolve_lock_payload,
34
- resolve_runner_status,
35
- state_stream,
36
- )
37
-
38
- ALT_SCREEN_ENTER = b"\x1b[?1049h"
39
-
40
-
41
- def build_base_routes(static_dir: Path) -> APIRouter:
42
- """Build routes for index, state, logs, and terminal WebSocket."""
43
- router = APIRouter()
44
-
45
- @router.get("/", include_in_schema=False)
46
- def index(request: Request):
47
- active_static = getattr(request.app.state, "static_dir", static_dir)
48
- index_path = active_static / "index.html"
49
- if not index_path.exists():
50
- if refresh_static_assets(request.app):
51
- active_static = request.app.state.static_dir
52
- index_path = active_static / "index.html"
53
- if not index_path.exists():
54
- raise HTTPException(
55
- status_code=500, detail="Static UI assets missing; reinstall package"
56
- )
57
- html = render_index_html(active_static, request.app.state.asset_version)
58
- return HTMLResponse(html, headers=index_response_headers())
59
-
60
- @router.get("/api/state", response_model=StateResponse)
61
- def get_state(request: Request):
62
- engine = request.app.state.engine
63
- config = request.app.state.config
64
- state = load_state(engine.state_path)
65
- outstanding, done = engine.docs.todos()
66
- status, runner_pid, running = resolve_runner_status(engine, state)
67
- lock_payload = resolve_lock_payload(engine)
68
- codex_model = config.codex_model or extract_flag_value(
69
- config.codex_args, "--model"
70
- )
71
- return {
72
- "last_run_id": state.last_run_id,
73
- "status": status,
74
- "last_exit_code": state.last_exit_code,
75
- "last_run_started_at": state.last_run_started_at,
76
- "last_run_finished_at": state.last_run_finished_at,
77
- "outstanding_count": len(outstanding),
78
- "done_count": len(done),
79
- "running": running,
80
- "runner_pid": runner_pid,
81
- **lock_payload,
82
- "terminal_idle_timeout_seconds": config.terminal_idle_timeout_seconds,
83
- "codex_model": codex_model or "auto",
84
- }
85
-
86
- @router.get("/api/version", response_model=VersionResponse)
87
- def get_version(request: Request):
88
- return {"asset_version": request.app.state.asset_version}
89
-
90
- @router.get("/api/state/stream")
91
- async def stream_state_endpoint(request: Request):
92
- engine = request.app.state.engine
93
- manager = request.app.state.manager
94
- shutdown_event = getattr(request.app.state, "shutdown_event", None)
95
- return StreamingResponse(
96
- state_stream(
97
- engine,
98
- manager,
99
- logger=request.app.state.logger,
100
- shutdown_event=shutdown_event,
101
- max_seconds=60.0,
102
- ),
103
- media_type="text/event-stream",
104
- headers=SSE_HEADERS,
105
- )
106
-
107
- @router.get("/api/logs")
108
- def get_logs(
109
- request: Request,
110
- run_id: Optional[int] = None,
111
- tail: Optional[int] = None,
112
- raw: bool = False,
113
- ):
114
- engine = request.app.state.engine
115
-
116
- def _build_response(
117
- text: str, *, run_id: Optional[int] = None, tail: Optional[int] = None
118
- ):
119
- if raw:
120
- return PlainTextResponse(text or "")
121
- payload = {"log": text or ""}
122
- if run_id is not None:
123
- payload["run_id"] = run_id
124
- if tail is not None:
125
- payload["tail"] = tail
126
- return JSONResponse(payload)
127
-
128
- if run_id is not None:
129
- block = engine.read_run_block(run_id)
130
- if not block:
131
- raise HTTPException(status_code=404, detail="run not found")
132
- return _build_response(block, run_id=run_id)
133
- if tail is not None:
134
- return _build_response(engine.tail_log(tail), tail=tail)
135
- state = load_state(engine.state_path)
136
- if state.last_run_id is None:
137
- return _build_response("")
138
- block = engine.read_run_block(state.last_run_id) or ""
139
- return _build_response(block, run_id=state.last_run_id)
140
-
141
- @router.get("/api/logs/stream")
142
- async def stream_logs_endpoint(request: Request):
143
- engine = request.app.state.engine
144
- shutdown_event = getattr(request.app.state, "shutdown_event", None)
145
- return StreamingResponse(
146
- log_stream(
147
- engine.log_path, shutdown_event=shutdown_event, max_seconds=60.0
148
- ),
149
- media_type="text/event-stream",
150
- headers=SSE_HEADERS,
151
- )
152
-
153
- @router.websocket("/api/terminal")
154
- async def terminal(ws: WebSocket):
155
- selected_protocol = None
156
- protocol_header = ws.headers.get("sec-websocket-protocol")
157
- if protocol_header:
158
- for entry in protocol_header.split(","):
159
- candidate = entry.strip()
160
- if not candidate:
161
- continue
162
- if candidate == "car-token":
163
- selected_protocol = candidate
164
- break
165
- if candidate.startswith("car-token-b64."):
166
- selected_protocol = candidate
167
- break
168
- if candidate.startswith("car-token."):
169
- selected_protocol = candidate
170
- break
171
- if selected_protocol:
172
- await ws.accept(subprotocol=selected_protocol)
173
- else:
174
- await ws.accept()
175
- app = ws.scope.get("app")
176
- if app is None:
177
- await ws.close()
178
- return
179
- # Track websocket for graceful shutdown during reload
180
- active_websockets = getattr(app.state, "active_websockets", None)
181
- if active_websockets is not None:
182
- active_websockets.add(ws)
183
- logger = app.state.logger
184
- engine = app.state.engine
185
- terminal_sessions: dict[str, ActiveSession] = app.state.terminal_sessions
186
- terminal_lock: asyncio.Lock = app.state.terminal_lock
187
- session_registry: dict[str, SessionRecord] = app.state.session_registry
188
- repo_to_session: dict[str, str] = app.state.repo_to_session
189
- session_env = getattr(app.state, "env", None)
190
- repo_path = str(engine.repo_root)
191
- state_path = engine.state_path
192
- agent = (ws.query_params.get("agent") or "codex").strip().lower()
193
- model = (ws.query_params.get("model") or "").strip() or None
194
- reasoning = (ws.query_params.get("reasoning") or "").strip() or None
195
- session_id = None
196
- active_session: Optional[ActiveSession] = None
197
- seen_update_interval = 5.0
198
-
199
- ws_input_bytes_total = 0
200
- ws_input_message_count = 0
201
- ws_rate_limit_window_start = time.monotonic()
202
- MAX_BYTES_PER_CONNECTION = 10 * 1024 * 1024
203
- MAX_MESSAGES_PER_WINDOW = 1000
204
- RATE_LIMIT_WINDOW_SECONDS = 60.0
205
-
206
- def _session_key(repo: str, agent: str) -> str:
207
- normalized = (agent or "").strip().lower()
208
- # Backwards-compatible keying:
209
- # - Codex sessions continue to use the bare repo path key so existing
210
- # CLI/API callers that only know `repo_path` keep working.
211
- # - Other agents (e.g. OpenCode) use a scoped `repo:agent` key.
212
- if not normalized or normalized == "codex":
213
- return repo
214
- return f"{repo}:{normalized}"
215
-
216
- client_session_id = ws.query_params.get("session_id")
217
- close_session_id = ws.query_params.get("close_session_id")
218
- mode = (ws.query_params.get("mode") or "").strip().lower()
219
- attach_only = mode == "attach"
220
- terminal_debug_param = (ws.query_params.get("terminal_debug") or "").strip()
221
- terminal_debug = terminal_debug_param.lower() in {"1", "true", "yes", "on"}
222
-
223
- def _mark_dirty() -> None:
224
- app.state.session_state_dirty = True
225
-
226
- def _maybe_persist_sessions(force: bool = False) -> None:
227
- now = time.time()
228
- last_write = app.state.session_state_last_write
229
- if not force and not app.state.session_state_dirty:
230
- return
231
- if not force and now - last_write < seen_update_interval:
232
- return
233
- persist_session_registry(state_path, session_registry, repo_to_session)
234
- app.state.session_state_last_write = now
235
- app.state.session_state_dirty = False
236
-
237
- def _touch_session(session_id: str) -> None:
238
- record = session_registry.get(session_id)
239
- if not record:
240
- return
241
- record.last_seen_at = now_iso()
242
- if record.status != "active":
243
- record.status = "active"
244
- _mark_dirty()
245
- _maybe_persist_sessions()
246
-
247
- async with terminal_lock:
248
- if client_session_id and client_session_id in terminal_sessions:
249
- active_session = terminal_sessions[client_session_id]
250
- if not active_session.pty.isalive():
251
- active_session.close()
252
- terminal_sessions.pop(client_session_id, None)
253
- session_registry.pop(client_session_id, None)
254
- repo_to_session = {
255
- _session_key(repo, ag): sid
256
- for repo, ag, sid in [
257
- (
258
- k.split(":", 1)[0],
259
- (
260
- (k.split(":", 1)[1] or "codex")
261
- if ":" in k
262
- else "codex"
263
- ),
264
- v,
265
- )
266
- for k, v in repo_to_session.items()
267
- ]
268
- if sid != client_session_id
269
- }
270
- app.state.repo_to_session = repo_to_session
271
- active_session = None
272
- _mark_dirty()
273
- else:
274
- session_id = client_session_id
275
-
276
- if not active_session:
277
- mapped_session_id = repo_to_session.get(_session_key(repo_path, agent))
278
- if mapped_session_id:
279
- mapped_session = terminal_sessions.get(mapped_session_id)
280
- if mapped_session and mapped_session.pty.isalive():
281
- active_session = mapped_session
282
- session_id = mapped_session_id
283
- else:
284
- if mapped_session:
285
- mapped_session.close()
286
- terminal_sessions.pop(mapped_session_id, None)
287
- session_registry.pop(mapped_session_id, None)
288
- repo_to_session.pop(_session_key(repo_path, agent), None)
289
- _mark_dirty()
290
- if attach_only:
291
- await ws.send_text(
292
- json.dumps(
293
- {
294
- "type": "error",
295
- "message": "Session not found",
296
- "session_id": client_session_id,
297
- }
298
- )
299
- )
300
- await ws.close()
301
- return
302
- if (
303
- close_session_id
304
- and close_session_id in terminal_sessions
305
- and close_session_id != client_session_id
306
- ):
307
- try:
308
- session_to_close = terminal_sessions[close_session_id]
309
- session_to_close.close()
310
- await session_to_close.wait_closed()
311
- finally:
312
- terminal_sessions.pop(close_session_id, None)
313
- session_registry.pop(close_session_id, None)
314
- repo_to_session = {
315
- _session_key(repo, ag): sid
316
- for repo, ag, sid in [
317
- (
318
- k.split(":", 1)[0],
319
- (
320
- (k.split(":", 1)[1] or "codex")
321
- if ":" in k
322
- else "codex"
323
- ),
324
- v,
325
- )
326
- for k, v in repo_to_session.items()
327
- ]
328
- if sid != close_session_id
329
- }
330
- app.state.repo_to_session = repo_to_session
331
- _mark_dirty()
332
- session_id = str(uuid.uuid4())
333
- resume_mode = mode == "resume"
334
- if agent == "opencode":
335
- cmd = build_opencode_terminal_cmd(
336
- engine.config.agent_binary("opencode"),
337
- model,
338
- )
339
- else:
340
- cmd = build_codex_terminal_cmd(
341
- engine,
342
- resume_mode=resume_mode,
343
- model=model,
344
- reasoning=reasoning,
345
- )
346
- try:
347
- pty = PTYSession(cmd, cwd=str(engine.repo_root), env=session_env)
348
- active_session = ActiveSession(
349
- session_id, pty, asyncio.get_running_loop()
350
- )
351
- terminal_sessions[session_id] = active_session
352
- session_registry[session_id] = SessionRecord(
353
- repo_path=repo_path,
354
- created_at=now_iso(),
355
- last_seen_at=now_iso(),
356
- status="active",
357
- agent=agent,
358
- )
359
- repo_to_session[_session_key(repo_path, agent)] = session_id
360
- _mark_dirty()
361
- except FileNotFoundError:
362
- binary = cmd[0] if cmd else "codex"
363
- await ws.send_text(
364
- json.dumps(
365
- {
366
- "type": "error",
367
- "message": f"Agent binary not found: {binary}",
368
- }
369
- )
370
- )
371
- await ws.close()
372
- return
373
- if active_session:
374
- if session_id and session_id not in session_registry:
375
- session_registry[session_id] = SessionRecord(
376
- repo_path=repo_path,
377
- created_at=now_iso(),
378
- last_seen_at=now_iso(),
379
- status="active",
380
- agent=agent,
381
- )
382
- _mark_dirty()
383
- if (
384
- session_id
385
- and repo_to_session.get(_session_key(repo_path, agent))
386
- != session_id
387
- ):
388
- repo_to_session[_session_key(repo_path, agent)] = session_id
389
- _mark_dirty()
390
- _maybe_persist_sessions(force=True)
391
-
392
- if attach_only and active_session:
393
- active_session.refresh_alt_screen_state()
394
- await ws.send_text(json.dumps({"type": "hello", "session_id": session_id}))
395
- if attach_only and active_session and active_session.alt_screen_active:
396
- await ws.send_bytes(ALT_SCREEN_ENTER)
397
- if terminal_debug and active_session:
398
- buffer_bytes, buffer_chunks = active_session.get_buffer_stats()
399
- safe_log(
400
- logger,
401
- logging.INFO,
402
- (
403
- "Terminal connect debug: mode="
404
- f"{mode} session={session_id} attach={attach_only} "
405
- f"alt_screen={active_session.alt_screen_active} "
406
- f"buffer_bytes={buffer_bytes} buffer_chunks={buffer_chunks}"
407
- ),
408
- )
409
- include_replay_end = attach_only or mode == "resume" or bool(client_session_id)
410
- if active_session is None:
411
- await ws.close()
412
- return
413
- queue = active_session.add_subscriber(include_replay_end=include_replay_end)
414
-
415
- async def pty_to_ws():
416
- try:
417
- while True:
418
- data = await queue.get()
419
- if data is REPLAY_END:
420
- await ws.send_text(json.dumps({"type": "replay_end"}))
421
- continue
422
- if data is None:
423
- if active_session:
424
- exit_code = active_session.pty.exit_code()
425
- if session_id:
426
- async with terminal_lock:
427
- record = session_registry.get(session_id)
428
- if record:
429
- record.status = "closed"
430
- record.last_seen_at = now_iso()
431
- _mark_dirty()
432
- notifier = getattr(engine, "notifier", None)
433
- if notifier:
434
- asyncio.create_task(
435
- notifier.notify_tui_session_finished_async(
436
- session_id=session_id,
437
- exit_code=exit_code,
438
- repo_path=repo_path,
439
- )
440
- )
441
- await ws.send_text(
442
- json.dumps(
443
- {
444
- "type": "exit",
445
- "code": exit_code,
446
- "session_id": session_id,
447
- }
448
- )
449
- )
450
- break
451
- await ws.send_bytes(data)
452
- if session_id:
453
- _touch_session(session_id)
454
- except Exception:
455
- safe_log(logger, logging.WARNING, "Terminal PTY to WS bridge failed")
456
-
457
- async def ws_to_pty():
458
- nonlocal ws_input_bytes_total, ws_input_message_count, ws_rate_limit_window_start
459
- try:
460
- while True:
461
- msg = await ws.receive()
462
- if msg["type"] == "websocket.disconnect":
463
- break
464
- if msg.get("bytes") is not None:
465
- ws_input_message_count += 1
466
- ws_input_bytes_total += len(msg["bytes"])
467
- if (
468
- ws_input_bytes_total > MAX_BYTES_PER_CONNECTION
469
- or ws_input_message_count > MAX_MESSAGES_PER_WINDOW
470
- ):
471
- await ws.close(code=1008, reason="Rate limit exceeded")
472
- return
473
- now = time.monotonic()
474
- if now - ws_rate_limit_window_start > RATE_LIMIT_WINDOW_SECONDS:
475
- ws_input_bytes_total = 0
476
- ws_input_message_count = 0
477
- ws_rate_limit_window_start = now
478
- # Queue input so PTY writes never block the event loop.
479
- active_session.write_input(msg["bytes"])
480
- active_session.mark_input_activity()
481
- if session_id:
482
- _touch_session(session_id)
483
- continue
484
- text = msg.get("text")
485
- if not text:
486
- continue
487
- try:
488
- payload = json.loads(text)
489
- except json.JSONDecodeError:
490
- continue
491
- if payload.get("type") == "resize":
492
- cols = int(payload.get("cols", 0))
493
- rows = int(payload.get("rows", 0))
494
- if cols > 0 and rows > 0:
495
- active_session.pty.resize(cols, rows)
496
- elif payload.get("type") == "input":
497
- input_id = payload.get("id")
498
- data = payload.get("data")
499
- if not input_id or not isinstance(input_id, str):
500
- await ws.send_text(
501
- json.dumps(
502
- {
503
- "type": "error",
504
- "message": "invalid input id",
505
- }
506
- )
507
- )
508
- continue
509
- if data is None or not isinstance(data, str):
510
- await ws.send_text(
511
- json.dumps(
512
- {
513
- "type": "ack",
514
- "id": input_id,
515
- "ok": False,
516
- "message": "invalid input data",
517
- }
518
- )
519
- )
520
- continue
521
- encoded = data.encode("utf-8", errors="replace")
522
- if len(encoded) > 1024 * 1024:
523
- await ws.send_text(
524
- json.dumps(
525
- {
526
- "type": "ack",
527
- "id": input_id,
528
- "ok": False,
529
- "message": "input too large",
530
- }
531
- )
532
- )
533
- continue
534
- ws_input_message_count += 1
535
- ws_input_bytes_total += len(encoded)
536
- if (
537
- ws_input_bytes_total > MAX_BYTES_PER_CONNECTION
538
- or ws_input_message_count > MAX_MESSAGES_PER_WINDOW
539
- ):
540
- await ws.close(code=1008, reason="Rate limit exceeded")
541
- return
542
- now = time.monotonic()
543
- if now - ws_rate_limit_window_start > RATE_LIMIT_WINDOW_SECONDS:
544
- ws_input_bytes_total = 0
545
- ws_input_message_count = 0
546
- ws_rate_limit_window_start = now
547
- if active_session.mark_input_id_seen(input_id):
548
- active_session.write_input(encoded)
549
- active_session.mark_input_activity()
550
- await ws.send_text(
551
- json.dumps({"type": "ack", "id": input_id, "ok": True})
552
- )
553
- if session_id:
554
- _touch_session(session_id)
555
- elif payload.get("type") == "ping":
556
- ws_input_message_count += 1
557
- if ws_input_message_count > MAX_MESSAGES_PER_WINDOW:
558
- await ws.close(code=1008, reason="Rate limit exceeded")
559
- return
560
- now = time.monotonic()
561
- if now - ws_rate_limit_window_start > RATE_LIMIT_WINDOW_SECONDS:
562
- ws_input_message_count = 0
563
- ws_rate_limit_window_start = now
564
- await ws.send_text(json.dumps({"type": "pong"}))
565
- if session_id:
566
- _touch_session(session_id)
567
- except WebSocketDisconnect:
568
- pass
569
- except Exception:
570
- safe_log(logger, logging.WARNING, "Terminal WS to PTY bridge failed")
571
-
572
- forward_task = asyncio.create_task(pty_to_ws())
573
- input_task = asyncio.create_task(ws_to_pty())
574
- try:
575
- done, pending = await asyncio.wait(
576
- [forward_task, input_task], return_when=asyncio.FIRST_COMPLETED
577
- )
578
- for task in done:
579
- try:
580
- task.result()
581
- except Exception:
582
- safe_log(logger, logging.WARNING, "Terminal websocket task failed")
583
- finally:
584
- forward_task.cancel()
585
- input_task.cancel()
586
-
587
- if active_session:
588
- active_session.remove_subscriber(queue)
589
- if not active_session.pty.isalive():
590
- async with terminal_lock:
591
- if session_id:
592
- terminal_sessions.pop(session_id, None)
593
- session_registry.pop(session_id, None)
594
- repo_to_session = {
595
- _session_key(repo, ag): sid
596
- for repo, ag, sid in [
597
- (
598
- k.split(":", 1)[0],
599
- (
600
- (k.split(":", 1)[1] or "codex")
601
- if ":" in k
602
- else "codex"
603
- ),
604
- v,
605
- )
606
- for k, v in repo_to_session.items()
607
- ]
608
- if sid != session_id
609
- }
610
- app.state.repo_to_session = repo_to_session
611
- _mark_dirty()
612
- if session_id:
613
- _touch_session(session_id)
614
- _maybe_persist_sessions(force=True)
615
-
616
- try:
617
- await ws.close()
618
- except Exception:
619
- safe_log(logger, logging.WARNING, "Terminal websocket close failed")
620
- finally:
621
- # Unregister websocket from active set
622
- if active_websockets is not None:
623
- active_websockets.discard(ws)
624
-
625
- return router
3
+ from ..surfaces.web.routes.base import * # noqa: F401,F403
@@ -0,0 +1,7 @@
1
+ """Backward-compatible file chat routes."""
2
+
3
+ import sys
4
+
5
+ from ..surfaces.web.routes import file_chat as _file_chat
6
+
7
+ sys.modules[__name__] = _file_chat
@@ -0,0 +1,7 @@
1
+ """Backward-compatible flow routes."""
2
+
3
+ import sys
4
+
5
+ from ..surfaces.web.routes import flows as _flows
6
+
7
+ sys.modules[__name__] = _flows
@@ -0,0 +1,7 @@
1
+ """Backward-compatible message routes."""
2
+
3
+ import sys
4
+
5
+ from ..surfaces.web.routes import messages as _messages
6
+
7
+ sys.modules[__name__] = _messages