codex-autorunner 0.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 (147) hide show
  1. codex_autorunner/__init__.py +3 -0
  2. codex_autorunner/bootstrap.py +151 -0
  3. codex_autorunner/cli.py +886 -0
  4. codex_autorunner/codex_cli.py +79 -0
  5. codex_autorunner/codex_runner.py +17 -0
  6. codex_autorunner/core/__init__.py +1 -0
  7. codex_autorunner/core/about_car.py +125 -0
  8. codex_autorunner/core/codex_runner.py +100 -0
  9. codex_autorunner/core/config.py +1465 -0
  10. codex_autorunner/core/doc_chat.py +547 -0
  11. codex_autorunner/core/docs.py +37 -0
  12. codex_autorunner/core/engine.py +720 -0
  13. codex_autorunner/core/git_utils.py +206 -0
  14. codex_autorunner/core/hub.py +756 -0
  15. codex_autorunner/core/injected_context.py +9 -0
  16. codex_autorunner/core/locks.py +57 -0
  17. codex_autorunner/core/logging_utils.py +158 -0
  18. codex_autorunner/core/notifications.py +465 -0
  19. codex_autorunner/core/optional_dependencies.py +41 -0
  20. codex_autorunner/core/prompt.py +107 -0
  21. codex_autorunner/core/prompts.py +275 -0
  22. codex_autorunner/core/request_context.py +21 -0
  23. codex_autorunner/core/runner_controller.py +116 -0
  24. codex_autorunner/core/runner_process.py +29 -0
  25. codex_autorunner/core/snapshot.py +576 -0
  26. codex_autorunner/core/state.py +156 -0
  27. codex_autorunner/core/update.py +567 -0
  28. codex_autorunner/core/update_runner.py +44 -0
  29. codex_autorunner/core/usage.py +1221 -0
  30. codex_autorunner/core/utils.py +108 -0
  31. codex_autorunner/discovery.py +102 -0
  32. codex_autorunner/housekeeping.py +423 -0
  33. codex_autorunner/integrations/__init__.py +1 -0
  34. codex_autorunner/integrations/app_server/__init__.py +6 -0
  35. codex_autorunner/integrations/app_server/client.py +1386 -0
  36. codex_autorunner/integrations/app_server/supervisor.py +206 -0
  37. codex_autorunner/integrations/github/__init__.py +10 -0
  38. codex_autorunner/integrations/github/service.py +889 -0
  39. codex_autorunner/integrations/telegram/__init__.py +1 -0
  40. codex_autorunner/integrations/telegram/adapter.py +1401 -0
  41. codex_autorunner/integrations/telegram/commands_registry.py +104 -0
  42. codex_autorunner/integrations/telegram/config.py +450 -0
  43. codex_autorunner/integrations/telegram/constants.py +154 -0
  44. codex_autorunner/integrations/telegram/dispatch.py +162 -0
  45. codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
  46. codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
  47. codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
  48. codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
  49. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
  50. codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
  51. codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
  52. codex_autorunner/integrations/telegram/helpers.py +2084 -0
  53. codex_autorunner/integrations/telegram/notifications.py +164 -0
  54. codex_autorunner/integrations/telegram/outbox.py +174 -0
  55. codex_autorunner/integrations/telegram/rendering.py +102 -0
  56. codex_autorunner/integrations/telegram/retry.py +37 -0
  57. codex_autorunner/integrations/telegram/runtime.py +270 -0
  58. codex_autorunner/integrations/telegram/service.py +921 -0
  59. codex_autorunner/integrations/telegram/state.py +1223 -0
  60. codex_autorunner/integrations/telegram/transport.py +318 -0
  61. codex_autorunner/integrations/telegram/types.py +57 -0
  62. codex_autorunner/integrations/telegram/voice.py +413 -0
  63. codex_autorunner/manifest.py +150 -0
  64. codex_autorunner/routes/__init__.py +53 -0
  65. codex_autorunner/routes/base.py +470 -0
  66. codex_autorunner/routes/docs.py +275 -0
  67. codex_autorunner/routes/github.py +197 -0
  68. codex_autorunner/routes/repos.py +121 -0
  69. codex_autorunner/routes/sessions.py +137 -0
  70. codex_autorunner/routes/shared.py +137 -0
  71. codex_autorunner/routes/system.py +175 -0
  72. codex_autorunner/routes/terminal_images.py +107 -0
  73. codex_autorunner/routes/voice.py +128 -0
  74. codex_autorunner/server.py +23 -0
  75. codex_autorunner/spec_ingest.py +113 -0
  76. codex_autorunner/static/app.js +95 -0
  77. codex_autorunner/static/autoRefresh.js +209 -0
  78. codex_autorunner/static/bootstrap.js +105 -0
  79. codex_autorunner/static/bus.js +23 -0
  80. codex_autorunner/static/cache.js +52 -0
  81. codex_autorunner/static/constants.js +48 -0
  82. codex_autorunner/static/dashboard.js +795 -0
  83. codex_autorunner/static/docs.js +1514 -0
  84. codex_autorunner/static/env.js +99 -0
  85. codex_autorunner/static/github.js +168 -0
  86. codex_autorunner/static/hub.js +1511 -0
  87. codex_autorunner/static/index.html +622 -0
  88. codex_autorunner/static/loader.js +28 -0
  89. codex_autorunner/static/logs.js +690 -0
  90. codex_autorunner/static/mobileCompact.js +300 -0
  91. codex_autorunner/static/snapshot.js +116 -0
  92. codex_autorunner/static/state.js +87 -0
  93. codex_autorunner/static/styles.css +4966 -0
  94. codex_autorunner/static/tabs.js +50 -0
  95. codex_autorunner/static/terminal.js +21 -0
  96. codex_autorunner/static/terminalManager.js +3535 -0
  97. codex_autorunner/static/todoPreview.js +25 -0
  98. codex_autorunner/static/types.d.ts +8 -0
  99. codex_autorunner/static/utils.js +597 -0
  100. codex_autorunner/static/vendor/LICENSE.xterm +24 -0
  101. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
  102. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
  103. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
  104. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
  105. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
  106. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
  107. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
  108. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
  109. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
  110. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
  111. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
  112. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
  113. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
  114. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
  115. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
  116. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
  117. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
  118. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
  119. codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
  120. codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
  121. codex_autorunner/static/vendor/xterm.css +209 -0
  122. codex_autorunner/static/vendor/xterm.js +2 -0
  123. codex_autorunner/static/voice.js +591 -0
  124. codex_autorunner/voice/__init__.py +39 -0
  125. codex_autorunner/voice/capture.py +349 -0
  126. codex_autorunner/voice/config.py +167 -0
  127. codex_autorunner/voice/provider.py +66 -0
  128. codex_autorunner/voice/providers/__init__.py +7 -0
  129. codex_autorunner/voice/providers/openai_whisper.py +345 -0
  130. codex_autorunner/voice/resolver.py +36 -0
  131. codex_autorunner/voice/service.py +210 -0
  132. codex_autorunner/web/__init__.py +1 -0
  133. codex_autorunner/web/app.py +1037 -0
  134. codex_autorunner/web/hub_jobs.py +181 -0
  135. codex_autorunner/web/middleware.py +552 -0
  136. codex_autorunner/web/pty_session.py +357 -0
  137. codex_autorunner/web/runner_manager.py +25 -0
  138. codex_autorunner/web/schemas.py +253 -0
  139. codex_autorunner/web/static_assets.py +430 -0
  140. codex_autorunner/web/terminal_sessions.py +78 -0
  141. codex_autorunner/workspace.py +16 -0
  142. codex_autorunner-0.1.0.dist-info/METADATA +240 -0
  143. codex_autorunner-0.1.0.dist-info/RECORD +147 -0
  144. codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
  145. codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
  146. codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
  147. codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,470 @@
1
+ """
2
+ Base routes: Index, state streaming, WebSocket terminal, and logs.
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 HTMLResponse, JSONResponse, StreamingResponse
15
+
16
+ from ..codex_cli import extract_flag_value
17
+ from ..core.logging_utils import safe_log
18
+ from ..core.state import SessionRecord, load_state, now_iso, persist_session_registry
19
+ from ..web.pty_session import REPLAY_END, ActiveSession, PTYSession
20
+ from ..web.schemas import StateResponse, VersionResponse
21
+ from ..web.static_assets import index_response_headers, render_index_html
22
+ from .shared import (
23
+ build_codex_terminal_cmd,
24
+ log_stream,
25
+ resolve_runner_status,
26
+ state_stream,
27
+ )
28
+
29
+ ALT_SCREEN_ENTER = b"\x1b[?1049h"
30
+ SSE_HEADERS = {
31
+ "Cache-Control": "no-cache",
32
+ "X-Accel-Buffering": "no",
33
+ "Connection": "keep-alive",
34
+ }
35
+
36
+
37
+ def build_base_routes(static_dir: Path) -> APIRouter:
38
+ """Build routes for index, state, logs, and terminal WebSocket."""
39
+ router = APIRouter()
40
+
41
+ @router.get("/", include_in_schema=False)
42
+ def index(request: Request):
43
+ index_path = static_dir / "index.html"
44
+ if not index_path.exists():
45
+ raise HTTPException(
46
+ status_code=500, detail="Static UI assets missing; reinstall package"
47
+ )
48
+ html = render_index_html(static_dir, request.app.state.asset_version)
49
+ return HTMLResponse(html, headers=index_response_headers())
50
+
51
+ @router.get("/api/state", response_model=StateResponse)
52
+ def get_state(request: Request):
53
+ engine = request.app.state.engine
54
+ config = request.app.state.config
55
+ state = load_state(engine.state_path)
56
+ outstanding, done = engine.docs.todos()
57
+ status, runner_pid, running = resolve_runner_status(engine, state)
58
+ codex_model = config.codex_model or extract_flag_value(
59
+ config.codex_args, "--model"
60
+ )
61
+ return {
62
+ "last_run_id": state.last_run_id,
63
+ "status": status,
64
+ "last_exit_code": state.last_exit_code,
65
+ "last_run_started_at": state.last_run_started_at,
66
+ "last_run_finished_at": state.last_run_finished_at,
67
+ "outstanding_count": len(outstanding),
68
+ "done_count": len(done),
69
+ "running": running,
70
+ "runner_pid": runner_pid,
71
+ "terminal_idle_timeout_seconds": config.terminal_idle_timeout_seconds,
72
+ "codex_model": codex_model or "auto",
73
+ }
74
+
75
+ @router.get("/api/version", response_model=VersionResponse)
76
+ def get_version(request: Request):
77
+ return {"asset_version": request.app.state.asset_version}
78
+
79
+ @router.get("/api/state/stream")
80
+ async def stream_state_endpoint(request: Request):
81
+ engine = request.app.state.engine
82
+ manager = request.app.state.manager
83
+ return StreamingResponse(
84
+ state_stream(engine, manager, logger=request.app.state.logger),
85
+ media_type="text/event-stream",
86
+ headers=SSE_HEADERS,
87
+ )
88
+
89
+ @router.get("/api/logs")
90
+ def get_logs(
91
+ request: Request, run_id: Optional[int] = None, tail: Optional[int] = None
92
+ ):
93
+ engine = request.app.state.engine
94
+ if run_id is not None:
95
+ block = engine.read_run_block(run_id)
96
+ if not block:
97
+ raise HTTPException(status_code=404, detail="run not found")
98
+ return JSONResponse({"run_id": run_id, "log": block})
99
+ if tail is not None:
100
+ return JSONResponse({"tail": tail, "log": engine.tail_log(tail)})
101
+ state = load_state(engine.state_path)
102
+ if state.last_run_id is None:
103
+ return JSONResponse({"log": ""})
104
+ block = engine.read_run_block(state.last_run_id) or ""
105
+ return JSONResponse({"run_id": state.last_run_id, "log": block})
106
+
107
+ @router.get("/api/logs/stream")
108
+ async def stream_logs_endpoint(request: Request):
109
+ engine = request.app.state.engine
110
+ return StreamingResponse(
111
+ log_stream(engine.log_path),
112
+ media_type="text/event-stream",
113
+ headers=SSE_HEADERS,
114
+ )
115
+
116
+ @router.websocket("/api/terminal")
117
+ async def terminal(ws: WebSocket):
118
+ selected_protocol = None
119
+ protocol_header = ws.headers.get("sec-websocket-protocol")
120
+ if protocol_header:
121
+ for entry in protocol_header.split(","):
122
+ candidate = entry.strip()
123
+ if not candidate:
124
+ continue
125
+ if candidate == "car-token":
126
+ selected_protocol = candidate
127
+ break
128
+ if candidate.startswith("car-token-b64."):
129
+ selected_protocol = candidate
130
+ break
131
+ if candidate.startswith("car-token."):
132
+ selected_protocol = candidate
133
+ break
134
+ if selected_protocol:
135
+ await ws.accept(subprotocol=selected_protocol)
136
+ else:
137
+ await ws.accept()
138
+ app = ws.scope.get("app")
139
+ if app is None:
140
+ await ws.close()
141
+ return
142
+ logger = app.state.logger
143
+ engine = app.state.engine
144
+ terminal_sessions: dict[str, ActiveSession] = app.state.terminal_sessions
145
+ terminal_lock: asyncio.Lock = app.state.terminal_lock
146
+ session_registry: dict[str, SessionRecord] = app.state.session_registry
147
+ repo_to_session: dict[str, str] = app.state.repo_to_session
148
+ repo_path = str(engine.repo_root)
149
+ state_path = engine.state_path
150
+
151
+ client_session_id = ws.query_params.get("session_id")
152
+ close_session_id = ws.query_params.get("close_session_id")
153
+ mode = (ws.query_params.get("mode") or "").strip().lower()
154
+ attach_only = mode == "attach"
155
+ terminal_debug_param = (ws.query_params.get("terminal_debug") or "").strip()
156
+ terminal_debug = terminal_debug_param.lower() in {"1", "true", "yes", "on"}
157
+ session_id = None
158
+ active_session: Optional[ActiveSession] = None
159
+ seen_update_interval = 5.0
160
+
161
+ def _mark_dirty() -> None:
162
+ app.state.session_state_dirty = True
163
+
164
+ def _maybe_persist_sessions(force: bool = False) -> None:
165
+ now = time.time()
166
+ last_write = app.state.session_state_last_write
167
+ if not force and not app.state.session_state_dirty:
168
+ return
169
+ if not force and now - last_write < seen_update_interval:
170
+ return
171
+ persist_session_registry(state_path, session_registry, repo_to_session)
172
+ app.state.session_state_last_write = now
173
+ app.state.session_state_dirty = False
174
+
175
+ def _touch_session(session_id: str) -> None:
176
+ record = session_registry.get(session_id)
177
+ if not record:
178
+ return
179
+ record.last_seen_at = now_iso()
180
+ if record.status != "active":
181
+ record.status = "active"
182
+ _mark_dirty()
183
+ _maybe_persist_sessions()
184
+
185
+ async with terminal_lock:
186
+ if client_session_id and client_session_id in terminal_sessions:
187
+ active_session = terminal_sessions[client_session_id]
188
+ if not active_session.pty.isalive():
189
+ active_session.close()
190
+ terminal_sessions.pop(client_session_id, None)
191
+ session_registry.pop(client_session_id, None)
192
+ repo_to_session = {
193
+ repo: sid
194
+ for repo, sid in repo_to_session.items()
195
+ if sid != client_session_id
196
+ }
197
+ app.state.repo_to_session = repo_to_session
198
+ active_session = None
199
+ _mark_dirty()
200
+ else:
201
+ session_id = client_session_id
202
+
203
+ if not active_session:
204
+ mapped_session_id = repo_to_session.get(repo_path)
205
+ if mapped_session_id:
206
+ mapped_session = terminal_sessions.get(mapped_session_id)
207
+ if mapped_session and mapped_session.pty.isalive():
208
+ active_session = mapped_session
209
+ session_id = mapped_session_id
210
+ else:
211
+ if mapped_session:
212
+ mapped_session.close()
213
+ terminal_sessions.pop(mapped_session_id, None)
214
+ session_registry.pop(mapped_session_id, None)
215
+ repo_to_session.pop(repo_path, None)
216
+ _mark_dirty()
217
+ if attach_only:
218
+ await ws.send_text(
219
+ json.dumps(
220
+ {
221
+ "type": "error",
222
+ "message": "Session not found",
223
+ "session_id": client_session_id,
224
+ }
225
+ )
226
+ )
227
+ await ws.close()
228
+ return
229
+ if (
230
+ close_session_id
231
+ and close_session_id in terminal_sessions
232
+ and close_session_id != client_session_id
233
+ ):
234
+ try:
235
+ session_to_close = terminal_sessions[close_session_id]
236
+ session_to_close.close()
237
+ await session_to_close.wait_closed()
238
+ finally:
239
+ terminal_sessions.pop(close_session_id, None)
240
+ session_registry.pop(close_session_id, None)
241
+ repo_to_session = {
242
+ repo: sid
243
+ for repo, sid in repo_to_session.items()
244
+ if sid != close_session_id
245
+ }
246
+ app.state.repo_to_session = repo_to_session
247
+ _mark_dirty()
248
+ session_id = str(uuid.uuid4())
249
+ resume_mode = mode == "resume"
250
+ cmd = build_codex_terminal_cmd(engine, resume_mode=resume_mode)
251
+ try:
252
+ pty = PTYSession(cmd, cwd=str(engine.repo_root))
253
+ active_session = ActiveSession(
254
+ session_id, pty, asyncio.get_running_loop()
255
+ )
256
+ terminal_sessions[session_id] = active_session
257
+ session_registry[session_id] = SessionRecord(
258
+ repo_path=repo_path,
259
+ created_at=now_iso(),
260
+ last_seen_at=now_iso(),
261
+ status="active",
262
+ )
263
+ repo_to_session[repo_path] = session_id
264
+ _mark_dirty()
265
+ except FileNotFoundError:
266
+ await ws.send_text(
267
+ json.dumps(
268
+ {
269
+ "type": "error",
270
+ "message": f"Codex binary not found: {engine.config.codex_binary}",
271
+ }
272
+ )
273
+ )
274
+ await ws.close()
275
+ return
276
+ if active_session:
277
+ if session_id and session_id not in session_registry:
278
+ session_registry[session_id] = SessionRecord(
279
+ repo_path=repo_path,
280
+ created_at=now_iso(),
281
+ last_seen_at=now_iso(),
282
+ status="active",
283
+ )
284
+ _mark_dirty()
285
+ if session_id and repo_to_session.get(repo_path) != session_id:
286
+ repo_to_session[repo_path] = session_id
287
+ _mark_dirty()
288
+ _maybe_persist_sessions(force=True)
289
+
290
+ if attach_only and active_session:
291
+ active_session.refresh_alt_screen_state()
292
+ await ws.send_text(json.dumps({"type": "hello", "session_id": session_id}))
293
+ if attach_only and active_session and active_session.alt_screen_active:
294
+ await ws.send_bytes(ALT_SCREEN_ENTER)
295
+ if terminal_debug and active_session:
296
+ buffer_bytes, buffer_chunks = active_session.get_buffer_stats()
297
+ safe_log(
298
+ logger,
299
+ logging.INFO,
300
+ (
301
+ "Terminal connect debug: mode="
302
+ f"{mode} session={session_id} attach={attach_only} "
303
+ f"alt_screen={active_session.alt_screen_active} "
304
+ f"buffer_bytes={buffer_bytes} buffer_chunks={buffer_chunks}"
305
+ ),
306
+ )
307
+ include_replay_end = attach_only or mode == "resume" or bool(client_session_id)
308
+ if active_session is None:
309
+ await ws.close()
310
+ return
311
+ queue = active_session.add_subscriber(include_replay_end=include_replay_end)
312
+
313
+ async def pty_to_ws():
314
+ try:
315
+ while True:
316
+ data = await queue.get()
317
+ if data is REPLAY_END:
318
+ await ws.send_text(json.dumps({"type": "replay_end"}))
319
+ continue
320
+ if data is None:
321
+ if active_session:
322
+ exit_code = active_session.pty.exit_code()
323
+ if session_id:
324
+ record = session_registry.get(session_id)
325
+ if record:
326
+ record.status = "closed"
327
+ record.last_seen_at = now_iso()
328
+ _mark_dirty()
329
+ notifier = getattr(engine, "notifier", None)
330
+ if notifier:
331
+ asyncio.create_task(
332
+ notifier.notify_tui_session_finished_async(
333
+ session_id=session_id,
334
+ exit_code=exit_code,
335
+ repo_path=repo_path,
336
+ )
337
+ )
338
+ await ws.send_text(
339
+ json.dumps(
340
+ {
341
+ "type": "exit",
342
+ "code": exit_code,
343
+ "session_id": session_id,
344
+ }
345
+ )
346
+ )
347
+ break
348
+ await ws.send_bytes(data)
349
+ if session_id:
350
+ _touch_session(session_id)
351
+ except Exception:
352
+ safe_log(logger, logging.WARNING, "Terminal PTY to WS bridge failed")
353
+
354
+ async def ws_to_pty():
355
+ try:
356
+ while True:
357
+ msg = await ws.receive()
358
+ if msg["type"] == "websocket.disconnect":
359
+ break
360
+ if msg.get("bytes") is not None:
361
+ # Queue input so PTY writes never block the event loop.
362
+ active_session.write_input(msg["bytes"])
363
+ active_session.mark_input_activity()
364
+ if session_id:
365
+ _touch_session(session_id)
366
+ continue
367
+ text = msg.get("text")
368
+ if not text:
369
+ continue
370
+ try:
371
+ payload = json.loads(text)
372
+ except json.JSONDecodeError:
373
+ continue
374
+ if payload.get("type") == "resize":
375
+ cols = int(payload.get("cols", 0))
376
+ rows = int(payload.get("rows", 0))
377
+ if cols > 0 and rows > 0:
378
+ active_session.pty.resize(cols, rows)
379
+ elif payload.get("type") == "input":
380
+ input_id = payload.get("id")
381
+ data = payload.get("data")
382
+ if not input_id or not isinstance(input_id, str):
383
+ await ws.send_text(
384
+ json.dumps(
385
+ {
386
+ "type": "error",
387
+ "message": "invalid input id",
388
+ }
389
+ )
390
+ )
391
+ continue
392
+ if data is None or not isinstance(data, str):
393
+ await ws.send_text(
394
+ json.dumps(
395
+ {
396
+ "type": "ack",
397
+ "id": input_id,
398
+ "ok": False,
399
+ "message": "invalid input data",
400
+ }
401
+ )
402
+ )
403
+ continue
404
+ encoded = data.encode("utf-8", errors="replace")
405
+ if len(encoded) > 1024 * 1024:
406
+ await ws.send_text(
407
+ json.dumps(
408
+ {
409
+ "type": "ack",
410
+ "id": input_id,
411
+ "ok": False,
412
+ "message": "input too large",
413
+ }
414
+ )
415
+ )
416
+ continue
417
+ if active_session.mark_input_id_seen(input_id):
418
+ active_session.write_input(encoded)
419
+ active_session.mark_input_activity()
420
+ await ws.send_text(
421
+ json.dumps({"type": "ack", "id": input_id, "ok": True})
422
+ )
423
+ if session_id:
424
+ _touch_session(session_id)
425
+ elif payload.get("type") == "ping":
426
+ await ws.send_text(json.dumps({"type": "pong"}))
427
+ if session_id:
428
+ _touch_session(session_id)
429
+ except WebSocketDisconnect:
430
+ pass
431
+ except Exception:
432
+ safe_log(logger, logging.WARNING, "Terminal WS to PTY bridge failed")
433
+
434
+ forward_task = asyncio.create_task(pty_to_ws())
435
+ input_task = asyncio.create_task(ws_to_pty())
436
+ done, pending = await asyncio.wait(
437
+ [forward_task, input_task], return_when=asyncio.FIRST_COMPLETED
438
+ )
439
+ for task in done:
440
+ try:
441
+ task.result()
442
+ except Exception:
443
+ safe_log(logger, logging.WARNING, "Terminal websocket task failed")
444
+
445
+ if active_session:
446
+ active_session.remove_subscriber(queue)
447
+ if not active_session.pty.isalive():
448
+ async with terminal_lock:
449
+ if session_id:
450
+ terminal_sessions.pop(session_id, None)
451
+ session_registry.pop(session_id, None)
452
+ repo_to_session = {
453
+ repo: sid
454
+ for repo, sid in repo_to_session.items()
455
+ if sid != session_id
456
+ }
457
+ app.state.repo_to_session = repo_to_session
458
+ _mark_dirty()
459
+ if session_id:
460
+ _touch_session(session_id)
461
+ _maybe_persist_sessions(force=True)
462
+
463
+ forward_task.cancel()
464
+ input_task.cancel()
465
+ try:
466
+ await ws.close()
467
+ except Exception:
468
+ safe_log(logger, logging.WARNING, "Terminal websocket close failed")
469
+
470
+ return router