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.
- codex_autorunner/__init__.py +3 -0
- codex_autorunner/bootstrap.py +151 -0
- codex_autorunner/cli.py +886 -0
- codex_autorunner/codex_cli.py +79 -0
- codex_autorunner/codex_runner.py +17 -0
- codex_autorunner/core/__init__.py +1 -0
- codex_autorunner/core/about_car.py +125 -0
- codex_autorunner/core/codex_runner.py +100 -0
- codex_autorunner/core/config.py +1465 -0
- codex_autorunner/core/doc_chat.py +547 -0
- codex_autorunner/core/docs.py +37 -0
- codex_autorunner/core/engine.py +720 -0
- codex_autorunner/core/git_utils.py +206 -0
- codex_autorunner/core/hub.py +756 -0
- codex_autorunner/core/injected_context.py +9 -0
- codex_autorunner/core/locks.py +57 -0
- codex_autorunner/core/logging_utils.py +158 -0
- codex_autorunner/core/notifications.py +465 -0
- codex_autorunner/core/optional_dependencies.py +41 -0
- codex_autorunner/core/prompt.py +107 -0
- codex_autorunner/core/prompts.py +275 -0
- codex_autorunner/core/request_context.py +21 -0
- codex_autorunner/core/runner_controller.py +116 -0
- codex_autorunner/core/runner_process.py +29 -0
- codex_autorunner/core/snapshot.py +576 -0
- codex_autorunner/core/state.py +156 -0
- codex_autorunner/core/update.py +567 -0
- codex_autorunner/core/update_runner.py +44 -0
- codex_autorunner/core/usage.py +1221 -0
- codex_autorunner/core/utils.py +108 -0
- codex_autorunner/discovery.py +102 -0
- codex_autorunner/housekeeping.py +423 -0
- codex_autorunner/integrations/__init__.py +1 -0
- codex_autorunner/integrations/app_server/__init__.py +6 -0
- codex_autorunner/integrations/app_server/client.py +1386 -0
- codex_autorunner/integrations/app_server/supervisor.py +206 -0
- codex_autorunner/integrations/github/__init__.py +10 -0
- codex_autorunner/integrations/github/service.py +889 -0
- codex_autorunner/integrations/telegram/__init__.py +1 -0
- codex_autorunner/integrations/telegram/adapter.py +1401 -0
- codex_autorunner/integrations/telegram/commands_registry.py +104 -0
- codex_autorunner/integrations/telegram/config.py +450 -0
- codex_autorunner/integrations/telegram/constants.py +154 -0
- codex_autorunner/integrations/telegram/dispatch.py +162 -0
- codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
- codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
- codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
- codex_autorunner/integrations/telegram/helpers.py +2084 -0
- codex_autorunner/integrations/telegram/notifications.py +164 -0
- codex_autorunner/integrations/telegram/outbox.py +174 -0
- codex_autorunner/integrations/telegram/rendering.py +102 -0
- codex_autorunner/integrations/telegram/retry.py +37 -0
- codex_autorunner/integrations/telegram/runtime.py +270 -0
- codex_autorunner/integrations/telegram/service.py +921 -0
- codex_autorunner/integrations/telegram/state.py +1223 -0
- codex_autorunner/integrations/telegram/transport.py +318 -0
- codex_autorunner/integrations/telegram/types.py +57 -0
- codex_autorunner/integrations/telegram/voice.py +413 -0
- codex_autorunner/manifest.py +150 -0
- codex_autorunner/routes/__init__.py +53 -0
- codex_autorunner/routes/base.py +470 -0
- codex_autorunner/routes/docs.py +275 -0
- codex_autorunner/routes/github.py +197 -0
- codex_autorunner/routes/repos.py +121 -0
- codex_autorunner/routes/sessions.py +137 -0
- codex_autorunner/routes/shared.py +137 -0
- codex_autorunner/routes/system.py +175 -0
- codex_autorunner/routes/terminal_images.py +107 -0
- codex_autorunner/routes/voice.py +128 -0
- codex_autorunner/server.py +23 -0
- codex_autorunner/spec_ingest.py +113 -0
- codex_autorunner/static/app.js +95 -0
- codex_autorunner/static/autoRefresh.js +209 -0
- codex_autorunner/static/bootstrap.js +105 -0
- codex_autorunner/static/bus.js +23 -0
- codex_autorunner/static/cache.js +52 -0
- codex_autorunner/static/constants.js +48 -0
- codex_autorunner/static/dashboard.js +795 -0
- codex_autorunner/static/docs.js +1514 -0
- codex_autorunner/static/env.js +99 -0
- codex_autorunner/static/github.js +168 -0
- codex_autorunner/static/hub.js +1511 -0
- codex_autorunner/static/index.html +622 -0
- codex_autorunner/static/loader.js +28 -0
- codex_autorunner/static/logs.js +690 -0
- codex_autorunner/static/mobileCompact.js +300 -0
- codex_autorunner/static/snapshot.js +116 -0
- codex_autorunner/static/state.js +87 -0
- codex_autorunner/static/styles.css +4966 -0
- codex_autorunner/static/tabs.js +50 -0
- codex_autorunner/static/terminal.js +21 -0
- codex_autorunner/static/terminalManager.js +3535 -0
- codex_autorunner/static/todoPreview.js +25 -0
- codex_autorunner/static/types.d.ts +8 -0
- codex_autorunner/static/utils.js +597 -0
- codex_autorunner/static/vendor/LICENSE.xterm +24 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
- codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
- codex_autorunner/static/vendor/xterm.css +209 -0
- codex_autorunner/static/vendor/xterm.js +2 -0
- codex_autorunner/static/voice.js +591 -0
- codex_autorunner/voice/__init__.py +39 -0
- codex_autorunner/voice/capture.py +349 -0
- codex_autorunner/voice/config.py +167 -0
- codex_autorunner/voice/provider.py +66 -0
- codex_autorunner/voice/providers/__init__.py +7 -0
- codex_autorunner/voice/providers/openai_whisper.py +345 -0
- codex_autorunner/voice/resolver.py +36 -0
- codex_autorunner/voice/service.py +210 -0
- codex_autorunner/web/__init__.py +1 -0
- codex_autorunner/web/app.py +1037 -0
- codex_autorunner/web/hub_jobs.py +181 -0
- codex_autorunner/web/middleware.py +552 -0
- codex_autorunner/web/pty_session.py +357 -0
- codex_autorunner/web/runner_manager.py +25 -0
- codex_autorunner/web/schemas.py +253 -0
- codex_autorunner/web/static_assets.py +430 -0
- codex_autorunner/web/terminal_sessions.py +78 -0
- codex_autorunner/workspace.py +16 -0
- codex_autorunner-0.1.0.dist-info/METADATA +240 -0
- codex_autorunner-0.1.0.dist-info/RECORD +147 -0
- codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
- codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
- codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|