diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index 668aa17..3bbf154 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -8045,7 +8045,7 @@ RULES: } const orchCfg = getOrchestrationConfig(); - if (orchestrationSkillEnabled && orchCfg?.enabled && !isBootStartupTurn) { + if (orchestrationSkillEnabled && orchCfg?.enabled && !isBootStartupTurn && !isCodeAiSession && !isProjSession) { if (!roundHadProgress) orchestrationState.recordRoundNoProgress(round); const { fire, reason } = orchestrationState.shouldTrigger( orchCfg, diff --git a/web-ui/code.js b/web-ui/code.js index 1a0d6a5..928e39a 100644 --- a/web-ui/code.js +++ b/web-ui/code.js @@ -492,6 +492,8 @@ let companionWs = null; let companionConnected = false; const COMPANION_URL = 'ws://127.0.0.1:9000'; let companionShellId = null; // code terminal shell ID backed by companion PTY +let companionPlatform = 'unix'; // 'windows' | 'unix', set on shell_ready +let companionHome = ''; // home directory from companion const localShellIds = new Set(); // shell IDs routed to companion instead of server PTY function companionConnect() { @@ -528,7 +530,12 @@ function handleCompanionMessage(msg) { setTimeout(() => { const s = codeTermShells.find(s => s.id === targetId) || codeTermShells.find(s => s.id === activeCodeTermShellId); if (!s || !s.term) return; - if (msg.type === 'shell_ready') s.term.write('\x1b[32m๐Ÿ–ฅ๏ธ ๋กœ์ปฌ ์…ธ\x1b[0m\r\n'); + if (msg.type === 'shell_ready') { + if (msg.platform) companionPlatform = msg.platform; + if (msg.home) companionHome = msg.home; + s.term.write('\x1b[32m๐Ÿ–ฅ๏ธ ๋กœ์ปฌ ์…ธ\x1b[0m\r\n'); + _companionCdProject(); + } else if (msg.type === 'shell_output') s.term.write(msg.data); else if (msg.type === 'shell_exit') { s.term.write(`\r\n\x1b[33m[์…ธ ์ข…๋ฃŒ ${msg.code}]\x1b[0m\r\n`); companionShellId = null; } else if (msg.type === 'started') s.term.write(`\r\n\x1b[32mโ–ถ ${msg.filename}\x1b[0m\r\n`); @@ -543,6 +550,17 @@ function companionKill() { if (companionWs && companionConnected) companionWs.send(JSON.stringify({ type: 'kill' })); } +function _companionCdProject() { + if (!companionConnected || !companionWs || !activeProjectName) return; + const isWin = companionPlatform === 'windows'; + const sep = isWin ? '\\' : '/'; + const base = (localTermPath || companionHome || '').replace(/[/\\]$/, ''); + if (!base) return; // ๊ฒฝ๋กœ ์ •๋ณด ์—†์œผ๋ฉด cd ์•ˆ ํ•จ + const target = base + sep + activeProjectName; + const cmd = isWin ? `cd /d "${target}"\r` : `cd ${JSON.stringify(target)}\r`; + companionWs.send(JSON.stringify({ type: 'input', data: cmd })); +} + function csbCodeDirChanged(val) {} function csbCodeDirSave() {} function csbLoadCodeDir() {} @@ -3645,6 +3663,7 @@ function switchProject(name) { updateLocalModeUI(); updateSessionPanel(); codeSbRefresh(); + if (companionShellId) _companionCdProject(); // ์ฑ„ํŒ… ํŒจ๋„ ๊ฐฑ์‹ : ํžˆ์Šคํ† ๋ฆฌ๊ฐ€ ์žˆ์œผ๋ฉด ๊ทธ๋Œ€๋กœ, ์—†์œผ๋ฉด ์„ธ์…˜ ๋ชฉ๋ก const _msgs = document.getElementById('code-ai-messages'); if (_msgs) { diff --git a/web-ui/companion.py b/web-ui/companion.py new file mode 100644 index 0000000..deeb7c6 --- /dev/null +++ b/web-ui/companion.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +""" +companion.py โ€” ์›น ์ฝ”๋“œ ์—๋””ํ„ฐ์šฉ ๋กœ์ปฌ Python ์‹คํ–‰ ์ปดํŒจ๋‹ˆ์–ธ + +์‚ฌ์šฉ๋ฒ•: + pip install websockets + python companion.py + +์›น ์—๋””ํ„ฐ(๋ธŒ๋ผ์šฐ์ €)๊ฐ€ ws://localhost:9000 ์— ์—ฐ๊ฒฐํ•ด์„œ +์ฝ”๋“œ ์‹คํ–‰ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด ์ด ํ”„๋กœ์„ธ์Šค๊ฐ€ ๋กœ์ปฌ์—์„œ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. +pygame ๋“ฑ GUI ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋„ ์ •์ƒ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค. +""" + +import asyncio +import json +import os +import sys +import tempfile +import threading + +try: + import websockets +except ImportError: + print('์˜ค๋ฅ˜: websockets ํŒจํ‚ค์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.') + print('์„ค์น˜: pip install websockets') + sys.exit(1) + +HOST = '127.0.0.1' +PORT = 9000 +IS_WINDOWS = sys.platform == 'win32' + + +async def _safe_send(ws, data: str): + try: + await ws.send(data) + except Exception: + pass + + +class Runner: + def __init__(self): + self.proc = None + self.tmp_path = None + self.master_fd = None # Unix PTY only + self._shell_task = None + + async def kill(self): + if IS_WINDOWS and hasattr(self, '_win_pty') and self._win_pty: + try: + self._win_pty.close() + except Exception: + pass + self._win_pty = None + if self._shell_task: + self._shell_task.cancel() + try: + await self._shell_task + except (asyncio.CancelledError, Exception): + pass + self._shell_task = None + if self.master_fd is not None: + try: + asyncio.get_running_loop().remove_reader(self.master_fd) + except Exception: + pass + try: + os.close(self.master_fd) + except Exception: + pass + self.master_fd = None + 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 + + async def start_shell(self, ws, rows: int = 24, cols: int = 80): + if IS_WINDOWS: + await self._start_shell_windows(ws, rows, cols) + else: + await self._start_shell_unix(ws, rows, cols) + + async def _start_shell_unix(self, ws, rows: int, cols: int): + 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, + close_fds=True, + env={**os.environ, 'TERM': 'xterm-256color'}, + ) + os.close(slave_fd) + self.master_fd = master_fd + + 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_shell_windows(self, ws, rows: int = 24, cols: int = 80): + 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) + 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)) + + async def _stream_shell_unix(self, ws): + loop = asyncio.get_running_loop() + done = asyncio.Event() + + def on_readable(): + fd = self.master_fd + if fd is None: + done.set() + return + try: + data = os.read(fd, 4096) + loop.create_task(_safe_send(ws, json.dumps({ + 'type': 'shell_output', + 'data': data.decode('utf-8', errors='replace'), + }))) + except BlockingIOError: + pass + except OSError: + try: + loop.remove_reader(fd) + except Exception: + pass + done.set() + + loop.add_reader(self.master_fd, on_readable) + print(f'[์…ธ์ŠคํŠธ๋ฆผ] ์‹œ์ž‘ fd={self.master_fd}', flush=True) + try: + await done.wait() + except asyncio.CancelledError: + fd = self.master_fd + if fd is not None: + try: + loop.remove_reader(fd) + except Exception: + pass + return + + if self.proc: + try: + await self.proc.wait() + except Exception: + pass + try: + await ws.send(json.dumps({ + 'type': 'shell_exit', + 'code': self.proc.returncode if self.proc else -1, + })) + except Exception: + pass + + async def _stream_shell_winpty(self, ws): + loop = asyncio.get_running_loop() + try: + while self._win_pty and self._win_pty.isalive(): + try: + data = await loop.run_in_executor(None, self._win_pty.read, 4096) + if data: + await _safe_send(ws, json.dumps({ + 'type': 'shell_output', + 'data': data, + })) + except EOFError: + break + except Exception: + break + except asyncio.CancelledError: + pass + try: + await ws.send(json.dumps({'type': 'shell_exit', 'code': 0})) + except Exception: + pass + + async def _stream_shell_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 + ) + if chunk: + await _safe_send(ws, json.dumps({ + 'type': 'shell_output', + 'data': chunk.decode('utf-8', errors='replace').replace('\x00', ''), + })) + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + pass + except Exception as e: + print(f'[pipe ์˜ค๋ฅ˜] {e}', flush=True) + + if self.proc: + try: + await self.proc.wait() + except Exception: + pass + try: + await ws.send(json.dumps({ + 'type': 'shell_exit', + 'code': self.proc.returncode if self.proc else -1, + })) + except Exception: + pass + + def resize(self, cols: int, rows: int): + if IS_WINDOWS: + if hasattr(self, '_win_pty') and self._win_pty: + try: + self._win_pty.setwinsize(rows, cols) + except Exception: + pass + return + if self.master_fd is None: + return + try: + import fcntl, termios, struct + winsize = struct.pack('HHHH', rows, cols, 0, 0) + fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize) + except Exception: + pass + + def write_input(self, data: str): + if IS_WINDOWS and hasattr(self, '_win_pty') and self._win_pty: + try: + self._win_pty.write(data) + except Exception as e: + print(f'[์ž…๋ ฅ์˜ค๋ฅ˜] {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) + elif 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): + runner = Runner() + addr = ws.remote_address + print(f'[์—ฐ๊ฒฐ] {addr}') + try: + async for raw in ws: + try: + msg = json.loads(raw) + except Exception: + continue + + t = msg.get('type') + + if t == 'run': + await runner.kill() + + code = msg.get('code', '') + filename = msg.get('filename', 'script.py') + ext = os.path.splitext(filename)[1] or '.py' + + with tempfile.NamedTemporaryFile( + mode='w', suffix=ext, delete=False, encoding='utf-8' + ) as f: + f.write(code) + runner.tmp_path = f.name + + print(f'[์‹คํ–‰] {filename}') + await ws.send(json.dumps({'type': 'started', 'filename': filename})) + + try: + runner.proc = await asyncio.create_subprocess_exec( + sys.executable, runner.tmp_path, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + env={**os.environ}, + ) + + while True: + chunk = await runner.proc.stdout.read(512) + if not chunk: + break + await ws.send(json.dumps({ + 'type': 'output', + 'data': chunk.decode('utf-8', errors='replace'), + })) + + await runner.proc.wait() + code_ret = runner.proc.returncode + print(f'[์ข…๋ฃŒ] {filename} โ†’ exit {code_ret}') + await ws.send(json.dumps({'type': 'exit', 'code': code_ret})) + + except Exception as e: + await ws.send(json.dumps({'type': 'error', 'data': str(e)})) + + finally: + if runner.tmp_path: + try: + os.unlink(runner.tmp_path) + except Exception: + pass + runner.tmp_path = None + + elif t == 'shell': + await runner.kill() + rows = msg.get('rows', 24) + cols = msg.get('cols', 80) + print(f'[์…ธ] ์‹œ์ž‘ ({cols}x{rows})') + await runner.start_shell(ws, rows=rows, cols=cols) + + elif t == 'resize': + runner.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 + + elif t == 'kill': + await runner.kill() + print('[๊ฐ•์ œ ์ข…๋ฃŒ]') + await ws.send(json.dumps({'type': 'killed'})) + + elif t == 'ping': + await ws.send(json.dumps({'type': 'pong'})) + + except Exception as e: + print(f'[์—ฐ๊ฒฐ์˜ค๋ฅ˜] {type(e).__name__}: {e}', flush=True) + finally: + await runner.kill() + print(f'[์ข…๋ฃŒ] {addr}', flush=True) + + +def _make_tray_icon(): + from PIL import Image, ImageDraw + size = 64 + img = Image.new('RGBA', (size, size), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + d.ellipse([2, 2, size - 2, size - 2], fill=(52, 120, 200)) + d.ellipse([18, 18, 46, 46], fill='white') + d.ellipse([24, 24, 40, 40], fill=(52, 120, 200)) + return img + + +def _run_asyncio_in_thread(stop_event): + async def _serve(): + async with websockets.serve(handle, HOST, PORT): + while not stop_event.is_set(): + await asyncio.sleep(0.3) + try: + asyncio.run(_serve()) + except Exception as e: + print(f'[์„œ๋ฒ„์˜ค๋ฅ˜] {e}', flush=True) + + +def _run_with_tray(): + import pystray + stop_event = threading.Event() + t = threading.Thread(target=_run_asyncio_in_thread, args=(stop_event,), daemon=True) + t.start() + + def on_quit(icon, item): + stop_event.set() + icon.stop() + + icon = pystray.Icon( + 'python_companion', + _make_tray_icon(), + f'Python Companion ws://{HOST}:{PORT}', + menu=pystray.Menu( + pystray.MenuItem('Python Companion', None, enabled=False), + pystray.MenuItem(f'ํฌํŠธ: {PORT} | ์—ฐ๊ฒฐ ๋Œ€๊ธฐ ์ค‘', None, enabled=False), + pystray.Menu.SEPARATOR, + pystray.MenuItem('์ข…๋ฃŒ', on_quit), + ), + ) + print('์‹œ์Šคํ…œ ํŠธ๋ ˆ์ด์—์„œ ์‹คํ–‰ ์ค‘ (์šฐํด๋ฆญ โ†’ ์ข…๋ฃŒ)', flush=True) + icon.run() + stop_event.set() + + +async def main(): + async with websockets.serve(handle, HOST, PORT): + await asyncio.Future() + + +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: + import pystray + from PIL import Image + _run_with_tray() + except ImportError: + print('์›น ์—๋””ํ„ฐ์—์„œ ์—ฐ๊ฒฐ ๋Œ€๊ธฐ ์ค‘...\nCtrl+C ๋กœ ์ข…๋ฃŒ\n', flush=True) + try: + asyncio.run(main()) + except KeyboardInterrupt: + print('\n์ปดํŒจ๋‹ˆ์–ธ ์ข…๋ฃŒ')