Add user workspaces, dental DB scripts, server memory + code editor improvements

- cherry: orofacial/TMD PPTX presentations + 음악가 구강건강 가이드
- papa: sinus/infraoccluded PPTX, garden-monitor Arduino, MIDI files
- databases: PCSP scraper, translation scripts, vision benchmark, batch JSONs
- web-ui: code.js/companion.py/styles.css major updates, autostart installer
- src: ollama-client, session, LLMProvider, ollama-adapter patches
- .gitignore: add claude-key, SQLite WAL/SHM, __pycache__, nohup.out

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-07 23:13:03 +09:00
co-authored by Claude Sonnet 4.6
parent 43942d4b2c
commit 508d11615a
147 changed files with 10164 additions and 286 deletions
+199 -73
View File
@@ -12,12 +12,19 @@ pygame 등 GUI 라이브러리도 정상 동작합니다.
"""
import asyncio
import io
import json
import os
import sys
import tempfile
import threading
# pythonw.exe에서는 sys.stdout/stderr가 None이므로 print() 크래시 방지
if sys.stdout is None:
sys.stdout = io.TextIOWrapper(io.BytesIO(), encoding='utf-8', errors='replace')
if sys.stderr is None:
sys.stderr = io.TextIOWrapper(io.BytesIO(), encoding='utf-8', errors='replace')
try:
import websockets
except ImportError:
@@ -30,6 +37,28 @@ PORT = 9000
IS_WINDOWS = sys.platform == 'win32'
def _refresh_env():
"""시작 후 변경된 환경 변수(PATH 등)를 레지스트리에서 실시간으로 반영한다."""
if not IS_WINDOWS:
return
try:
import winreg
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
r'SYSTEM\CurrentControlSet\Control\Session Manager\Environment',
0, winreg.KEY_READ) as key:
sys_path = winreg.QueryValueEx(key, 'Path')[0]
with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r'Environment',
0, winreg.KEY_READ) as key:
user_path = winreg.QueryValueEx(key, 'Path')[0]
expanded = os.path.expandvars(sys_path + ';' + user_path)
existing = set(p.strip().lower() for p in os.environ.get('Path', '').split(';') if p.strip())
new_entries = [p for p in expanded.split(';') if p.strip() and p.strip().lower() not in existing]
os.environ['Path'] = os.environ.get('Path', '') + ';' + ';'.join(new_entries)
except Exception:
pass
async def _safe_send(ws, data: str):
try:
await ws.send(data)
@@ -37,17 +66,21 @@ async def _safe_send(ws, data: str):
pass
class Runner:
def __init__(self):
class ShellSession:
"""shellId별 독립 PTY 셸 세션."""
def __init__(self, shell_id: str):
self.shell_id = shell_id
self.proc = None
self.tmp_path = None
self.master_fd = None # Unix PTY only
self.master_fd = None
self._shell_task = None
self._win_pty = None
async def kill(self):
if IS_WINDOWS and hasattr(self, '_win_pty') and self._win_pty:
if IS_WINDOWS and self._win_pty:
try:
self._win_pty.close()
if hasattr(self._win_pty, 'close'):
self._win_pty.close()
except Exception:
pass
self._win_pty = None
@@ -74,29 +107,29 @@ class Runner:
await self.proc.wait()
except Exception:
pass
if self.tmp_path:
try:
os.unlink(self.tmp_path)
except Exception:
pass
self.tmp_path = None
self.proc = None
async def start_shell(self, ws, rows: int = 24, cols: int = 80):
def _ready_msg(self):
return json.dumps({
'type': 'shell_ready',
'shellId': self.shell_id,
'platform': 'windows' if IS_WINDOWS else 'unix',
'home': os.path.expanduser('~'),
})
async def start(self, ws, rows: int = 24, cols: int = 80):
if IS_WINDOWS:
await self._start_shell_windows(ws, rows, cols)
await self._start_windows(ws, rows, cols)
else:
await self._start_shell_unix(ws, rows, cols)
await self._start_unix(ws, rows, cols)
async def _start_shell_unix(self, ws, rows: int, cols: int):
async def _start_unix(self, ws, rows, cols):
import pty, fcntl, termios, struct
master_fd, slave_fd = pty.openpty()
winsize = struct.pack('HHHH', rows, cols, 0, 0)
fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, winsize)
fl = fcntl.fcntl(master_fd, fcntl.F_GETFL)
fcntl.fcntl(master_fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
self.proc = await asyncio.create_subprocess_exec(
'/bin/bash', '--login',
stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
@@ -105,33 +138,48 @@ class Runner:
)
os.close(slave_fd)
self.master_fd = master_fd
await ws.send(self._ready_msg())
self._shell_task = asyncio.create_task(self._stream_unix(ws))
await ws.send(json.dumps({'type': 'shell_ready', 'platform': 'windows' if IS_WINDOWS else 'unix', 'home': os.path.expanduser('~')}))
self._shell_task = asyncio.create_task(self._stream_shell_unix(ws))
async def _start_windows(self, ws, rows, cols):
# 1) pywinpty 2.x
try:
from pywinpty import Pty
self._win_pty = Pty(cols, rows)
self._win_pty.spawn('cmd.exe')
await ws.send(self._ready_msg())
self._shell_task = asyncio.create_task(self._stream_winpty(ws))
print(f'[셸:{self.shell_id}] pywinpty 2.x', flush=True)
return
except ImportError:
pass
except Exception as e:
print(f'[셸:{self.shell_id}] pywinpty 2.x 실패: {e}', flush=True)
async def _start_shell_windows(self, ws, rows: int = 24, cols: int = 80):
# 2) pywinpty 0.x
try:
from winpty import PtyProcess
self._win_pty = PtyProcess.spawn(
['cmd.exe'],
dimensions=(rows, cols),
)
await ws.send(json.dumps({'type': 'shell_ready', 'platform': 'windows' if IS_WINDOWS else 'unix', 'home': os.path.expanduser('~')}))
self._shell_task = asyncio.create_task(self._stream_shell_winpty(ws))
print('[셸] winpty 사용', flush=True)
self._win_pty = PtyProcess.spawn(['cmd.exe'], dimensions=(rows, cols))
await ws.send(self._ready_msg())
self._shell_task = asyncio.create_task(self._stream_winpty(ws))
print(f'[셸:{self.shell_id}] pywinpty 0.x', flush=True)
return
except ImportError:
print('[셸] pywinpty 없음 → pipe 모드 (pip install pywinpty 권장)', flush=True)
self.proc = await asyncio.create_subprocess_exec(
'cmd.exe', '/K', 'chcp 65001 > nul',
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env={**os.environ},
)
await ws.send(json.dumps({'type': 'shell_ready', 'platform': 'windows' if IS_WINDOWS else 'unix', 'home': os.path.expanduser('~')}))
self._shell_task = asyncio.create_task(self._stream_shell_pipe(ws))
pass
async def _stream_shell_unix(self, ws):
# 3) pipe fallback
print(f'[셸:{self.shell_id}] pipe 모드 (pip install pywinpty 권장)', flush=True)
self.proc = await asyncio.create_subprocess_exec(
'cmd.exe', '/K', 'chcp 65001 > nul',
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env={**os.environ},
)
await ws.send(self._ready_msg())
self._shell_task = asyncio.create_task(self._stream_pipe(ws))
async def _stream_unix(self, ws):
loop = asyncio.get_running_loop()
done = asyncio.Event()
@@ -144,6 +192,7 @@ class Runner:
data = os.read(fd, 4096)
loop.create_task(_safe_send(ws, json.dumps({
'type': 'shell_output',
'shellId': self.shell_id,
'data': data.decode('utf-8', errors='replace'),
})))
except BlockingIOError:
@@ -156,7 +205,7 @@ class Runner:
done.set()
loop.add_reader(self.master_fd, on_readable)
print(f'[셸스트림] 시작 fd={self.master_fd}', flush=True)
print(f'[셸스트림:{self.shell_id}] 시작 fd={self.master_fd}', flush=True)
try:
await done.wait()
except asyncio.CancelledError:
@@ -176,43 +225,55 @@ class Runner:
try:
await ws.send(json.dumps({
'type': 'shell_exit',
'shellId': self.shell_id,
'code': self.proc.returncode if self.proc else -1,
}))
except Exception:
pass
async def _stream_shell_winpty(self, ws):
async def _stream_winpty(self, ws):
loop = asyncio.get_running_loop()
try:
while self._win_pty and self._win_pty.isalive():
while self._win_pty:
is_alive = False
try:
if hasattr(self._win_pty, 'is_alive'):
is_alive = self._win_pty.is_alive()
elif hasattr(self._win_pty, 'isalive'):
is_alive = self._win_pty.isalive()
except Exception:
break
if not is_alive:
break
try:
data = await loop.run_in_executor(None, self._win_pty.read, 4096)
if data:
await _safe_send(ws, json.dumps({
'type': 'shell_output',
'shellId': self.shell_id,
'data': data,
}))
except EOFError:
break
except Exception:
except Exception as e:
print(f'[winpty 스트림:{self.shell_id}] {e}', flush=True)
break
except asyncio.CancelledError:
pass
try:
await ws.send(json.dumps({'type': 'shell_exit', 'code': 0}))
await ws.send(json.dumps({'type': 'shell_exit', 'shellId': self.shell_id, 'code': 0}))
except Exception:
pass
async def _stream_shell_pipe(self, ws):
async def _stream_pipe(self, ws):
try:
while self.proc and self.proc.returncode is None:
try:
chunk = await asyncio.wait_for(
self.proc.stdout.read(512), timeout=0.05
)
chunk = await asyncio.wait_for(self.proc.stdout.read(512), timeout=0.05)
if chunk:
await _safe_send(ws, json.dumps({
'type': 'shell_output',
'shellId': self.shell_id,
'data': chunk.decode('utf-8', errors='replace').replace('\x00', ''),
}))
except asyncio.TimeoutError:
@@ -220,8 +281,7 @@ class Runner:
except asyncio.CancelledError:
pass
except Exception as e:
print(f'[pipe 오류] {e}', flush=True)
print(f'[pipe 오류:{self.shell_id}] {e}', flush=True)
if self.proc:
try:
await self.proc.wait()
@@ -230,6 +290,7 @@ class Runner:
try:
await ws.send(json.dumps({
'type': 'shell_exit',
'shellId': self.shell_id,
'code': self.proc.returncode if self.proc else -1,
}))
except Exception:
@@ -237,9 +298,12 @@ class Runner:
def resize(self, cols: int, rows: int):
if IS_WINDOWS:
if hasattr(self, '_win_pty') and self._win_pty:
if self._win_pty:
try:
self._win_pty.setwinsize(rows, cols)
if hasattr(self._win_pty, 'set_size'):
self._win_pty.set_size(cols, rows)
elif hasattr(self._win_pty, 'setwinsize'):
self._win_pty.setwinsize(rows, cols)
except Exception:
pass
return
@@ -253,16 +317,16 @@ class Runner:
pass
def write_input(self, data: str):
if IS_WINDOWS and hasattr(self, '_win_pty') and self._win_pty:
if IS_WINDOWS and self._win_pty:
try:
self._win_pty.write(data)
except Exception as e:
print(f'[입력오류] {e}', flush=True)
print(f'[입력오류:{self.shell_id}] {e}', flush=True)
elif self.master_fd is not None:
try:
os.write(self.master_fd, data.encode('utf-8'))
except Exception as e:
print(f'[입력오류] {e}', flush=True)
print(f'[입력오류:{self.shell_id}] {e}', flush=True)
elif self.proc and self.proc.stdin and self.proc.returncode is None:
try:
self.proc.stdin.write(data.encode('utf-8'))
@@ -270,7 +334,38 @@ class Runner:
pass
class Runner:
"""▶ 버튼 실행 전용 (run 메시지)."""
def __init__(self):
self.proc = None
self.tmp_path = None
async def kill(self):
if self.proc and self.proc.returncode is None:
try:
self.proc.kill()
await self.proc.wait()
except Exception:
pass
if self.tmp_path:
try:
os.unlink(self.tmp_path)
except Exception:
pass
self.tmp_path = None
self.proc = None
def write_input(self, data: str):
if self.proc and self.proc.stdin and self.proc.returncode is None:
try:
self.proc.stdin.write(data.encode('utf-8'))
except Exception:
pass
async def handle(ws):
shells = {} # shell_id -> ShellSession
runner = Runner()
addr = ws.remote_address
print(f'[연결] {addr}')
@@ -285,6 +380,7 @@ async def handle(ws):
if t == 'run':
await runner.kill()
_refresh_env()
code = msg.get('code', '')
filename = msg.get('filename', 'script.py')
@@ -334,27 +430,52 @@ async def handle(ws):
runner.tmp_path = None
elif t == 'shell':
await runner.kill()
shell_id = msg.get('shellId', 'default')
rows = msg.get('rows', 24)
cols = msg.get('cols', 80)
print(f'[셸] 시작 ({cols}x{rows})')
await runner.start_shell(ws, rows=rows, cols=cols)
_refresh_env()
if shell_id in shells:
await shells[shell_id].kill()
session = ShellSession(shell_id)
shells[shell_id] = session
print(f'[셸:{shell_id}] 시작 ({cols}x{rows})')
await session.start(ws, rows=rows, cols=cols)
elif t == 'shell_close':
shell_id = msg.get('shellId')
if shell_id and shell_id in shells:
await shells[shell_id].kill()
del shells[shell_id]
print(f'[셸:{shell_id}] 닫힘')
elif t == 'resize':
runner.resize(msg.get('cols', 80), msg.get('rows', 24))
shell_id = msg.get('shellId')
if shell_id and shell_id in shells:
shells[shell_id].resize(msg.get('cols', 80), msg.get('rows', 24))
elif t == 'input':
runner.write_input(msg.get('data', ''))
if runner.proc and runner.proc.stdin and runner.proc.returncode is None and IS_WINDOWS:
try:
await runner.proc.stdin.drain()
except Exception:
pass
shell_id = msg.get('shellId')
if shell_id and shell_id in shells:
shells[shell_id].write_input(msg.get('data', ''))
elif not shell_id:
runner.write_input(msg.get('data', ''))
if runner.proc and runner.proc.stdin and runner.proc.returncode is None and IS_WINDOWS:
try:
await runner.proc.stdin.drain()
except Exception:
pass
elif t == 'kill':
await runner.kill()
print('[강제 종료]')
await ws.send(json.dumps({'type': 'killed'}))
shell_id = msg.get('shellId')
if shell_id and shell_id in shells:
await shells[shell_id].kill()
del shells[shell_id]
print(f'[셸:{shell_id}] 강제종료')
await ws.send(json.dumps({'type': 'killed', 'shellId': shell_id}))
else:
await runner.kill()
print('[강제 종료]')
await ws.send(json.dumps({'type': 'killed'}))
elif t == 'ping':
await ws.send(json.dumps({'type': 'pong'}))
@@ -362,6 +483,8 @@ async def handle(ws):
except Exception as e:
print(f'[연결오류] {type(e).__name__}: {e}', flush=True)
finally:
for session in list(shells.values()):
await session.kill()
await runner.kill()
print(f'[종료] {addr}', flush=True)
@@ -420,11 +543,14 @@ async def main():
if __name__ == '__main__':
print('━' * 40)
print(f'🐍 Python Companion ws://{HOST}:{PORT}')
print(f'플랫폼: {"Windows" if IS_WINDOWS else "Unix/Linux"}')
print('━' * 40)
print(f'Python: {sys.executable}')
try:
print('━' * 40)
print(f'Python Companion ws://{HOST}:{PORT}')
print(f'플랫폼: {"Windows" if IS_WINDOWS else "Unix/Linux"}')
print('━' * 40)
print(f'Python: {sys.executable}')
except Exception:
pass
try:
import pystray