Spaces:
Paused
Paused
Upload app.py with huggingface_hub
Browse files
app.py
CHANGED
|
@@ -10,6 +10,7 @@ import asyncio
|
|
| 10 |
import json
|
| 11 |
import base64
|
| 12 |
import logging
|
|
|
|
| 13 |
import uuid
|
| 14 |
|
| 15 |
import spaces
|
|
@@ -40,10 +41,18 @@ ALLOWED_BASES = [os.path.abspath(p) for p in (
|
|
| 40 |
"/app", "/home/user", "/workspace", "/home/app", HOME
|
| 41 |
)]
|
| 42 |
|
| 43 |
-
#
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
# 每個 session 保留的畫面回放緩衝區大小上限(bytes)
|
| 46 |
SESSION_BUFFER_MAX = 300_000
|
|
|
|
|
|
|
| 47 |
# ──────────────────────────── Frontend ────────────────────────────
|
| 48 |
HTML_PAGE = r"""<!DOCTYPE html>
|
| 49 |
<html lang="zh-TW">
|
|
@@ -227,6 +236,9 @@ function connect(tabObj) {
|
|
| 227 |
if (m.type === 'session') {
|
| 228 |
// 伺服器告訴我們這條連線對應的 session id:記住它,下次斷線/重整才能接回同一個終端機
|
| 229 |
tabObj.sessionId = m.id;
|
|
|
|
|
|
|
|
|
|
| 230 |
if (m.reattached) setAppStatus(`終端機 ${tabObj.id} 已接回原工作階段`, 'success');
|
| 231 |
saveTabs();
|
| 232 |
} else if (m.type === 'output') {
|
|
@@ -259,13 +271,13 @@ function connect(tabObj) {
|
|
| 259 |
ws.onerror = () => ws.close();
|
| 260 |
}
|
| 261 |
|
| 262 |
-
function createTab(type = 'terminal', sessionId = null) {
|
| 263 |
const id = ++cnt;
|
| 264 |
|
| 265 |
const el = document.createElement('div');
|
| 266 |
el.className = 'tab';
|
| 267 |
el.dataset.id = id;
|
| 268 |
-
el.innerHTML = `<span class="status-dot"></span><span>終端機 ${id}</span><span class="x">×</span>`;
|
| 269 |
bar.insertBefore(el, copyBtn);
|
| 270 |
|
| 271 |
const wrap = document.createElement('div');
|
|
@@ -287,7 +299,7 @@ function createTab(type = 'terminal', sessionId = null) {
|
|
| 287 |
term.open(wrap);
|
| 288 |
fit.fit();
|
| 289 |
|
| 290 |
-
const obj = {id, type: 'terminal', el, wrap, term, fit, ws: null, closed: false, currentDir: '~', sessionId: sessionId || null};
|
| 291 |
tabs.push(obj);
|
| 292 |
connect(obj);
|
| 293 |
saveTabs();
|
|
@@ -339,7 +351,7 @@ function closeTab(id) {
|
|
| 339 |
if (activeId === id) activate(tabs[Math.min(i, tabs.length - 1)].id);
|
| 340 |
}
|
| 341 |
|
| 342 |
-
addBtn.onclick = () => createTab();
|
| 343 |
|
| 344 |
copyBtn.onclick = async () => {
|
| 345 |
const t = activeTab();
|
|
@@ -563,7 +575,7 @@ window.addEventListener('resize', () => tabs.forEach(t => {
|
|
| 563 |
}));
|
| 564 |
|
| 565 |
document.addEventListener('keydown', e => {
|
| 566 |
-
if (e.ctrlKey && e.shiftKey && e.key === 'T') { e.preventDefault(); createTab(); }
|
| 567 |
if (e.ctrlKey && e.shiftKey && e.key === 'W') { e.preventDefault(); if (activeId) closeTab(activeId); }
|
| 568 |
});
|
| 569 |
|
|
@@ -572,8 +584,16 @@ document.addEventListener('keydown', e => {
|
|
| 572 |
const saved = loadTabs();
|
| 573 |
const savedCnt = parseInt(localStorage.getItem('webterm_cnt') || '0', 10);
|
| 574 |
if (savedCnt) cnt = savedCnt;
|
| 575 |
-
|
| 576 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
})();
|
| 578 |
</script>
|
| 579 |
</body>
|
|
@@ -634,6 +654,27 @@ async def download_route(path: str):
|
|
| 634 |
return FileResponse(safe, filename=os.path.basename(safe))
|
| 635 |
|
| 636 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 637 |
# ──────────────────────────── 背景安裝任務 ────────────────────────────
|
| 638 |
|
| 639 |
async def _run(cmd, cwd=None, env=None):
|
|
@@ -821,6 +862,9 @@ def dummy_gpu():
|
|
| 821 |
@app.on_event("startup")
|
| 822 |
async def startup_event():
|
| 823 |
dummy_gpu()
|
|
|
|
|
|
|
|
|
|
| 824 |
# 安裝 OpenCode 後清除使用者層級設定,使用 OpenCode 預設值。
|
| 825 |
await install_opencode()
|
| 826 |
reset_opencode_config()
|
|
@@ -841,18 +885,23 @@ SESSIONS_LOCK = asyncio.Lock()
|
|
| 841 |
|
| 842 |
|
| 843 |
class TerminalSession:
|
| 844 |
-
def __init__(self, session_id: str, pid: int, fd: int, cols: int = 120, rows: int = 30
|
|
|
|
| 845 |
self.id = session_id
|
| 846 |
self.pid = pid
|
| 847 |
self.fd = fd
|
| 848 |
self.cols = cols
|
| 849 |
self.rows = rows
|
|
|
|
|
|
|
|
|
|
| 850 |
self.buffer = bytearray() # 畫面回放緩衝區,供重連時還原終端機畫面
|
| 851 |
self.subscribers: dict[int, asyncio.Queue] = {}
|
| 852 |
self._next_sub_id = 0
|
| 853 |
self.cleanup_task: asyncio.Task | None = None
|
| 854 |
|
| 855 |
def add_subscriber(self):
|
|
|
|
| 856 |
sid = self._next_sub_id
|
| 857 |
self._next_sub_id += 1
|
| 858 |
q: asyncio.Queue = asyncio.Queue()
|
|
@@ -864,15 +913,18 @@ class TerminalSession:
|
|
| 864 |
|
| 865 |
def remove_subscriber(self, sid: int, loop: asyncio.AbstractEventLoop):
|
| 866 |
self.subscribers.pop(sid, None)
|
| 867 |
-
|
|
|
|
| 868 |
self.cleanup_task = loop.create_task(self._schedule_cleanup(loop))
|
| 869 |
|
| 870 |
async def _schedule_cleanup(self, loop: asyncio.AbstractEventLoop):
|
|
|
|
|
|
|
| 871 |
try:
|
| 872 |
await asyncio.sleep(SESSION_IDLE_TIMEOUT)
|
| 873 |
except asyncio.CancelledError:
|
| 874 |
return
|
| 875 |
-
if not self.subscribers:
|
| 876 |
log.info("session %s 閒置超過 %ds 無人接回,清除", self.id, SESSION_IDLE_TIMEOUT)
|
| 877 |
await kill_session(self.id, loop)
|
| 878 |
|
|
@@ -918,11 +970,12 @@ def _fork_shell():
|
|
| 918 |
return pid, fd
|
| 919 |
|
| 920 |
|
| 921 |
-
async def create_session(cols: int = 120, rows: int = 30
|
|
|
|
| 922 |
pid, fd = _fork_shell()
|
| 923 |
|
| 924 |
-
session_id = uuid.uuid4().hex[:12]
|
| 925 |
-
sess = TerminalSession(session_id, pid, fd, cols, rows)
|
| 926 |
|
| 927 |
_set_winsize(fd, cols, rows)
|
| 928 |
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
|
|
@@ -952,6 +1005,20 @@ async def create_session(cols: int = 120, rows: int = 30) -> TerminalSession:
|
|
| 952 |
return sess
|
| 953 |
|
| 954 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 955 |
async def _reap_process(pid: int, loop: asyncio.AbstractEventLoop):
|
| 956 |
"""非阻塞地終止並回收子行程。"""
|
| 957 |
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
@@ -1010,8 +1077,21 @@ async def terminal_ws(websocket: WebSocket):
|
|
| 1010 |
except ValueError:
|
| 1011 |
cols, rows = 120, 30
|
| 1012 |
|
| 1013 |
-
|
| 1014 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1015 |
|
| 1016 |
if sess is None:
|
| 1017 |
try:
|
|
@@ -1025,14 +1105,18 @@ async def terminal_ws(websocket: WebSocket):
|
|
| 1025 |
return
|
| 1026 |
|
| 1027 |
# 告知前端這次連線對應的 session id,讓前端存起來,日後斷線重連 / 重新整理都能接回同一個
|
| 1028 |
-
await websocket.send_text(json.dumps({
|
|
|
|
|
|
|
|
|
|
| 1029 |
|
| 1030 |
if reattached:
|
| 1031 |
if sess.buffer:
|
| 1032 |
try:
|
|
|
|
| 1033 |
await websocket.send_text(json.dumps({
|
| 1034 |
"type": "output",
|
| 1035 |
-
"data": bytes(sess.buffer).decode("utf-8", errors="replace"),
|
| 1036 |
}))
|
| 1037 |
except Exception:
|
| 1038 |
pass
|
|
@@ -1043,7 +1127,7 @@ async def terminal_ws(websocket: WebSocket):
|
|
| 1043 |
}))
|
| 1044 |
except Exception:
|
| 1045 |
pass
|
| 1046 |
-
elif requested_id:
|
| 1047 |
# 前端要求接回某個 session,但伺服器已找不到(逾時被清除或伺服器重啟過)
|
| 1048 |
try:
|
| 1049 |
await websocket.send_text(json.dumps({
|
|
@@ -1055,6 +1139,14 @@ async def terminal_ws(websocket: WebSocket):
|
|
| 1055 |
|
| 1056 |
sub_id, queue = sess.add_subscriber()
|
| 1057 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1058 |
async def _pty_to_ws():
|
| 1059 |
while True:
|
| 1060 |
chunk = await queue.get()
|
|
@@ -1116,8 +1208,15 @@ async def terminal_ws(websocket: WebSocket):
|
|
| 1116 |
}))
|
| 1117 |
|
| 1118 |
elif mtype == "kill":
|
| 1119 |
-
#
|
| 1120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1121 |
break
|
| 1122 |
|
| 1123 |
except (WebSocketDisconnect, RuntimeError):
|
|
|
|
| 10 |
import json
|
| 11 |
import base64
|
| 12 |
import logging
|
| 13 |
+
import time
|
| 14 |
import uuid
|
| 15 |
|
| 16 |
import spaces
|
|
|
|
| 41 |
"/app", "/home/user", "/workspace", "/home/app", HOME
|
| 42 |
)]
|
| 43 |
|
| 44 |
+
# 共用主 session:所有裝置/瀏覽器預設都接到這一個,且永不被閒置清除。
|
| 45 |
+
# 沒有帶 session_id 的 /ws 連線一律接這裡;前端第一頁固定用這個,所以
|
| 46 |
+
# 不同裝置、斷線數小時後都會回到同一個終端機(opencode 也還在原地跑)。
|
| 47 |
+
PRIMARY_SESSION_ID = "primary"
|
| 48 |
+
# 前端按「+」開新分頁時送這個 id,代表「給我一個新的獨立 session」
|
| 49 |
+
NEW_SESSION_ID = "__new__"
|
| 50 |
+
# 非共用分頁斷線後保留的閒置時間(沒有任何分頁接回就會被清除)
|
| 51 |
+
SESSION_IDLE_TIMEOUT = int(os.environ.get("SESSION_IDLE_TIMEOUT", str(30 * 60)))
|
| 52 |
# 每個 session 保留的畫面回放緩衝區大小上限(bytes)
|
| 53 |
SESSION_BUFFER_MAX = 300_000
|
| 54 |
+
# 本機測試時可跳過 opencode / 編輯器安裝
|
| 55 |
+
SKIP_INSTALL = os.environ.get("WEBTERM_SKIP_INSTALL", "") == "1"
|
| 56 |
# ──────────────────────────── Frontend ────────────────────────────
|
| 57 |
HTML_PAGE = r"""<!DOCTYPE html>
|
| 58 |
<html lang="zh-TW">
|
|
|
|
| 236 |
if (m.type === 'session') {
|
| 237 |
// 伺服器告訴我們這條連線對應的 session id:記住它,下次斷線/重整才能接回同一個終端機
|
| 238 |
tabObj.sessionId = m.id;
|
| 239 |
+
tabObj.shared = !!m.primary;
|
| 240 |
+
const titleEl = tabObj.el.querySelector('.tab-title');
|
| 241 |
+
if (titleEl) titleEl.textContent = `終端機 ${tabObj.id}${tabObj.shared ? '(共用)' : ''}`;
|
| 242 |
if (m.reattached) setAppStatus(`終端機 ${tabObj.id} 已接回原工作階段`, 'success');
|
| 243 |
saveTabs();
|
| 244 |
} else if (m.type === 'output') {
|
|
|
|
| 271 |
ws.onerror = () => ws.close();
|
| 272 |
}
|
| 273 |
|
| 274 |
+
function createTab(type = 'terminal', sessionId = null, shared = false) {
|
| 275 |
const id = ++cnt;
|
| 276 |
|
| 277 |
const el = document.createElement('div');
|
| 278 |
el.className = 'tab';
|
| 279 |
el.dataset.id = id;
|
| 280 |
+
el.innerHTML = `<span class="status-dot"></span><span class="tab-title">終端機 ${id}${shared ? '(共用)' : ''}</span><span class="x">×</span>`;
|
| 281 |
bar.insertBefore(el, copyBtn);
|
| 282 |
|
| 283 |
const wrap = document.createElement('div');
|
|
|
|
| 299 |
term.open(wrap);
|
| 300 |
fit.fit();
|
| 301 |
|
| 302 |
+
const obj = {id, type: 'terminal', el, wrap, term, fit, ws: null, closed: false, currentDir: '~', sessionId: sessionId || null, shared: !!shared};
|
| 303 |
tabs.push(obj);
|
| 304 |
connect(obj);
|
| 305 |
saveTabs();
|
|
|
|
| 351 |
if (activeId === id) activate(tabs[Math.min(i, tabs.length - 1)].id);
|
| 352 |
}
|
| 353 |
|
| 354 |
+
addBtn.onclick = () => createTab('terminal', '__new__');
|
| 355 |
|
| 356 |
copyBtn.onclick = async () => {
|
| 357 |
const t = activeTab();
|
|
|
|
| 575 |
}));
|
| 576 |
|
| 577 |
document.addEventListener('keydown', e => {
|
| 578 |
+
if (e.ctrlKey && e.shiftKey && e.key === 'T') { e.preventDefault(); createTab('terminal', '__new__'); }
|
| 579 |
if (e.ctrlKey && e.shiftKey && e.key === 'W') { e.preventDefault(); if (activeId) closeTab(activeId); }
|
| 580 |
});
|
| 581 |
|
|
|
|
| 584 |
const saved = loadTabs();
|
| 585 |
const savedCnt = parseInt(localStorage.getItem('webterm_cnt') || '0', 10);
|
| 586 |
if (savedCnt) cnt = savedCnt;
|
| 587 |
+
// 第一頁固定不帶 session_id:伺服器會接到「共用主 session」。
|
| 588 |
+
// 如此一來不同裝置/不同瀏覽器、甚至斷線數小時後,都會回到同一個終端機,
|
| 589 |
+
// 不會再開一個新的 bash 把原本的 opencode 留在背景。
|
| 590 |
+
createTab('terminal', null, true);
|
| 591 |
+
// 這台裝置額外開的分頁才用 localStorage 還原(非共用)。
|
| 592 |
+
if (saved && saved.length > 1) {
|
| 593 |
+
saved.slice(1).forEach(t => {
|
| 594 |
+
if (t && t.type === 'terminal') createTab('terminal', t.sessionId || null, false);
|
| 595 |
+
});
|
| 596 |
+
}
|
| 597 |
})();
|
| 598 |
</script>
|
| 599 |
</body>
|
|
|
|
| 654 |
return FileResponse(safe, filename=os.path.basename(safe))
|
| 655 |
|
| 656 |
|
| 657 |
+
@app.get("/api/sessions")
|
| 658 |
+
async def api_sessions():
|
| 659 |
+
"""列出目前伺服器上的終端機工作階段(除錯/���認跨裝置是否共用)。"""
|
| 660 |
+
now = time.time()
|
| 661 |
+
async with SESSIONS_LOCK:
|
| 662 |
+
items = [{
|
| 663 |
+
"id": s.id,
|
| 664 |
+
"primary": s.id == PRIMARY_SESSION_ID,
|
| 665 |
+
"persistent": s.persistent,
|
| 666 |
+
"pid": s.pid,
|
| 667 |
+
"cols": s.cols,
|
| 668 |
+
"rows": s.rows,
|
| 669 |
+
"subscribers": len(s.subscribers),
|
| 670 |
+
"created_at": round(s.created_at, 3),
|
| 671 |
+
"last_active": round(s.last_active, 3),
|
| 672 |
+
"idle_seconds": round(now - s.last_active, 3),
|
| 673 |
+
"buffer_bytes": len(s.buffer),
|
| 674 |
+
} for s in SESSIONS.values()]
|
| 675 |
+
return {"primary": PRIMARY_SESSION_ID, "idle_timeout": SESSION_IDLE_TIMEOUT, "sessions": items}
|
| 676 |
+
|
| 677 |
+
|
| 678 |
# ──────────────────────────── 背景安裝任務 ────────────────────────────
|
| 679 |
|
| 680 |
async def _run(cmd, cwd=None, env=None):
|
|
|
|
| 862 |
@app.on_event("startup")
|
| 863 |
async def startup_event():
|
| 864 |
dummy_gpu()
|
| 865 |
+
if SKIP_INSTALL:
|
| 866 |
+
log.info("WEBTERM_SKIP_INSTALL=1:跳過 opencode / 編輯器安裝")
|
| 867 |
+
return
|
| 868 |
# 安裝 OpenCode 後清除使用者層級設定,使用 OpenCode 預設值。
|
| 869 |
await install_opencode()
|
| 870 |
reset_opencode_config()
|
|
|
|
| 885 |
|
| 886 |
|
| 887 |
class TerminalSession:
|
| 888 |
+
def __init__(self, session_id: str, pid: int, fd: int, cols: int = 120, rows: int = 30,
|
| 889 |
+
persistent: bool = False):
|
| 890 |
self.id = session_id
|
| 891 |
self.pid = pid
|
| 892 |
self.fd = fd
|
| 893 |
self.cols = cols
|
| 894 |
self.rows = rows
|
| 895 |
+
self.persistent = persistent # 共用主 session:永不被閒置清除
|
| 896 |
+
self.created_at = time.time()
|
| 897 |
+
self.last_active = self.created_at
|
| 898 |
self.buffer = bytearray() # 畫面回放緩衝區,供重連時還原終端機畫面
|
| 899 |
self.subscribers: dict[int, asyncio.Queue] = {}
|
| 900 |
self._next_sub_id = 0
|
| 901 |
self.cleanup_task: asyncio.Task | None = None
|
| 902 |
|
| 903 |
def add_subscriber(self):
|
| 904 |
+
self.last_active = time.time()
|
| 905 |
sid = self._next_sub_id
|
| 906 |
self._next_sub_id += 1
|
| 907 |
q: asyncio.Queue = asyncio.Queue()
|
|
|
|
| 913 |
|
| 914 |
def remove_subscriber(self, sid: int, loop: asyncio.AbstractEventLoop):
|
| 915 |
self.subscribers.pop(sid, None)
|
| 916 |
+
self.last_active = time.time()
|
| 917 |
+
if not self.subscribers and not self.persistent:
|
| 918 |
self.cleanup_task = loop.create_task(self._schedule_cleanup(loop))
|
| 919 |
|
| 920 |
async def _schedule_cleanup(self, loop: asyncio.AbstractEventLoop):
|
| 921 |
+
if self.persistent:
|
| 922 |
+
return
|
| 923 |
try:
|
| 924 |
await asyncio.sleep(SESSION_IDLE_TIMEOUT)
|
| 925 |
except asyncio.CancelledError:
|
| 926 |
return
|
| 927 |
+
if not self.subscribers and not self.persistent:
|
| 928 |
log.info("session %s 閒置超過 %ds 無人接回,清除", self.id, SESSION_IDLE_TIMEOUT)
|
| 929 |
await kill_session(self.id, loop)
|
| 930 |
|
|
|
|
| 970 |
return pid, fd
|
| 971 |
|
| 972 |
|
| 973 |
+
async def create_session(cols: int = 120, rows: int = 30, session_id: str | None = None,
|
| 974 |
+
persistent: bool = False) -> TerminalSession:
|
| 975 |
pid, fd = _fork_shell()
|
| 976 |
|
| 977 |
+
session_id = session_id or uuid.uuid4().hex[:12]
|
| 978 |
+
sess = TerminalSession(session_id, pid, fd, cols, rows, persistent=persistent)
|
| 979 |
|
| 980 |
_set_winsize(fd, cols, rows)
|
| 981 |
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
|
|
|
|
| 1005 |
return sess
|
| 1006 |
|
| 1007 |
|
| 1008 |
+
PRIMARY_LOCK = asyncio.Lock()
|
| 1009 |
+
|
| 1010 |
+
|
| 1011 |
+
async def get_or_create_primary(cols: int = 120, rows: int = 30) -> tuple[TerminalSession, bool]:
|
| 1012 |
+
"""回傳(共用主 session, 是否為接回)。所有沒指定 session_id 的連線都走這裡。"""
|
| 1013 |
+
async with PRIMARY_LOCK:
|
| 1014 |
+
sess = SESSIONS.get(PRIMARY_SESSION_ID)
|
| 1015 |
+
if sess is not None:
|
| 1016 |
+
return sess, True
|
| 1017 |
+
sess = await create_session(cols, rows, session_id=PRIMARY_SESSION_ID, persistent=True)
|
| 1018 |
+
log.info("已建立共用主 session(id=%s)", PRIMARY_SESSION_ID)
|
| 1019 |
+
return sess, False
|
| 1020 |
+
|
| 1021 |
+
|
| 1022 |
async def _reap_process(pid: int, loop: asyncio.AbstractEventLoop):
|
| 1023 |
"""非阻塞地終止並回收子行程。"""
|
| 1024 |
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
|
|
| 1077 |
except ValueError:
|
| 1078 |
cols, rows = 120, 30
|
| 1079 |
|
| 1080 |
+
# 共用主 session:
|
| 1081 |
+
# * 沒有帶 session_id(新裝置/新瀏覽器)→ 接到 primary
|
| 1082 |
+
# * session_id=__new__(按 + 開新分頁)→ 建立獨立的新 session
|
| 1083 |
+
# * 帶 primary 但伺服器沒有(例如 app 重啟)→ 重建 primary
|
| 1084 |
+
# * 其他 id 且找不到 → 建立一般的暫時 session
|
| 1085 |
+
if not requested_id:
|
| 1086 |
+
sess, reattached = await get_or_create_primary(cols, rows)
|
| 1087 |
+
elif requested_id == NEW_SESSION_ID:
|
| 1088 |
+
sess, reattached = None, False
|
| 1089 |
+
else:
|
| 1090 |
+
sess = SESSIONS.get(requested_id)
|
| 1091 |
+
reattached = sess is not None
|
| 1092 |
+
if sess is None and requested_id == PRIMARY_SESSION_ID:
|
| 1093 |
+
sess = await create_session(cols, rows, session_id=PRIMARY_SESSION_ID, persistent=True)
|
| 1094 |
+
reattached = False
|
| 1095 |
|
| 1096 |
if sess is None:
|
| 1097 |
try:
|
|
|
|
| 1105 |
return
|
| 1106 |
|
| 1107 |
# 告知前端這次連線對應的 session id,讓前端存起來,日後斷線重連 / 重新整理都能接回同一個
|
| 1108 |
+
await websocket.send_text(json.dumps({
|
| 1109 |
+
"type": "session", "id": sess.id, "reattached": reattached,
|
| 1110 |
+
"primary": sess.id == PRIMARY_SESSION_ID,
|
| 1111 |
+
}))
|
| 1112 |
|
| 1113 |
if reattached:
|
| 1114 |
if sess.buffer:
|
| 1115 |
try:
|
| 1116 |
+
# 先清畫面再回放,避免新裝置接到半個畫面
|
| 1117 |
await websocket.send_text(json.dumps({
|
| 1118 |
"type": "output",
|
| 1119 |
+
"data": "\x1b[2J\x1b[3J\x1b[H" + bytes(sess.buffer).decode("utf-8", errors="replace"),
|
| 1120 |
}))
|
| 1121 |
except Exception:
|
| 1122 |
pass
|
|
|
|
| 1127 |
}))
|
| 1128 |
except Exception:
|
| 1129 |
pass
|
| 1130 |
+
elif requested_id and requested_id not in (PRIMARY_SESSION_ID, NEW_SESSION_ID):
|
| 1131 |
# 前端要求接回某個 session,但伺服器已找不到(逾時被清除或伺服器重啟過)
|
| 1132 |
try:
|
| 1133 |
await websocket.send_text(json.dumps({
|
|
|
|
| 1139 |
|
| 1140 |
sub_id, queue = sess.add_subscriber()
|
| 1141 |
|
| 1142 |
+
if reattached:
|
| 1143 |
+
# 觸發 SIGWINCH,讓 TUI(opencode 等)重畫目前畫面;新裝置才不會只看到殘缺的回放
|
| 1144 |
+
try:
|
| 1145 |
+
_set_winsize(sess.fd, sess.cols, max(1, sess.rows - 1))
|
| 1146 |
+
_set_winsize(sess.fd, sess.cols, sess.rows)
|
| 1147 |
+
except Exception:
|
| 1148 |
+
pass
|
| 1149 |
+
|
| 1150 |
async def _pty_to_ws():
|
| 1151 |
while True:
|
| 1152 |
chunk = await queue.get()
|
|
|
|
| 1208 |
}))
|
| 1209 |
|
| 1210 |
elif mtype == "kill":
|
| 1211 |
+
# 關閉分頁只終止「非共用」的 session;共用主 session 只解除訂閱,
|
| 1212 |
+
# 這樣其他裝置接回來時 opencode 還在原地跑。
|
| 1213 |
+
if sess.id == PRIMARY_SESSION_ID:
|
| 1214 |
+
await websocket.send_text(json.dumps({
|
| 1215 |
+
"type": "system",
|
| 1216 |
+
"data": "\r\n\x1b[33m[ℹ️ 共用終端機不會因關閉分頁而終止,已保留工作階段]\x1b[0m\r\n",
|
| 1217 |
+
}))
|
| 1218 |
+
else:
|
| 1219 |
+
await kill_session(sess.id, loop)
|
| 1220 |
break
|
| 1221 |
|
| 1222 |
except (WebSocketDisconnect, RuntimeError):
|