1 Commits

Author SHA1 Message Date
537839ba1a Add Codex target commands and session routing 2026-02-17 04:24:07 +09:00
9 changed files with 203 additions and 28 deletions

View File

@@ -8,6 +8,7 @@ SLACK_ALLOWED_CHANNEL_ID=C0XXXXXXXXX
# PTY / tmux 연결 설정 # PTY / tmux 연결 설정
TMUX_SESSION_NAME=claude TMUX_SESSION_NAME=claude
CODEX_TMUX_SESSION_NAME=codex
PTY_READ_TIMEOUT=5 PTY_READ_TIMEOUT=5
# 출력 버퍼 설정 # 출력 버퍼 설정

View File

@@ -1,12 +1,14 @@
# LazyEnter # LazyEnter
소파에서 엔터나 치자. 소파에서 엔터나 치자.
로컬 PC의 Claude Code CLI 세션을 Slack Socket Mode로 중계해 모바일에서도 원격으로 입력/승인을 처리하는 브릿지입니다. 로컬 PC의 Claude/Codex CLI 세션을 Slack Socket Mode로 중계해 모바일에서도 원격으로 입력/승인을 처리하는 브릿지입니다.
## 동작 방식 ## 동작 방식
1. 로컬에서 Claude를 tmux 세션으로 미리 실행 (`tmux new -s claude claude`) 1. 로컬에서 Claude 또는 Codex를 tmux 세션으로 미리 실행
2. Slack에서 `/start-claude` 실행 - Claude: `tmux new -s claude claude`
- Codex: `tmux new -s codex codex`
2. Slack에서 `/start-claude` 또는 `/start-codex` 실행
3. 브릿지가 기존 tmux 세션에 attach 3. 브릿지가 기존 tmux 세션에 attach
4. Slack 채널 메시지가 CLI 입력으로 전달됨 4. Slack 채널 메시지가 CLI 입력으로 전달됨
5. CLI 출력이 Slack으로 다시 전송됨 5. CLI 출력이 Slack으로 다시 전송됨
@@ -35,7 +37,8 @@ cp .env.example .env
- `SLACK_ALLOWED_CHANNEL_ID` - `SLACK_ALLOWED_CHANNEL_ID`
선택 환경 변수: 선택 환경 변수:
- `TMUX_SESSION_NAME` (기본: `claude`) - `TMUX_SESSION_NAME` (기본: `claude`, `/start-claude` 대상)
- `CODEX_TMUX_SESSION_NAME` (기본: `codex`, `/start-codex` 대상)
- `PTY_READ_TIMEOUT` (기본: `5`) - `PTY_READ_TIMEOUT` (기본: `5`)
- `OUTPUT_BUFFER_INTERVAL` (기본: `2.0`) - `OUTPUT_BUFFER_INTERVAL` (기본: `2.0`)
- `MAX_MESSAGE_LENGTH` (기본: `3000`) - `MAX_MESSAGE_LENGTH` (기본: `3000`)
@@ -52,14 +55,18 @@ cp .env.example .env
- Slash Commands 생성: - Slash Commands 생성:
- `/start-claude` - `/start-claude`
- `/stop-claude` - `/stop-claude`
- `/start-codex`
- `/stop-codex`
- 앱을 워크스페이스에 설치 후 토큰을 `.env`에 반영 - 앱을 워크스페이스에 설치 후 토큰을 `.env`에 반영
## 실행 ## 실행
먼저 로컬에서 Claude tmux 세션을 실행: 먼저 로컬에서 원하는 tmux 세션을 실행:
```bash ```bash
tmux new -s claude claude tmux new -s claude claude
# 또는
tmux new -s codex codex
``` ```
그 다음 브릿지 실행: 그 다음 브릿지 실행:
@@ -71,9 +78,9 @@ python -m lazy_enter
``` ```
실행 후 Slack의 허용된 채널에서: 실행 후 Slack의 허용된 채널에서:
- `/start-claude`: 기존 세션에 연결 - `/start-claude`, `/start-codex`: 기존 세션에 연결
- 일반 메시지 전송: Claude CLI로 입력 전달 - 일반 메시지 전송: 현재 연결된 CLI(Claude/Codex)로 입력 전달
- `/stop-claude`: 브릿지 연결 해제 (세션 유지) - `/stop-claude`, `/stop-codex`: 브릿지 연결 해제 (세션 유지)
## 테스트 및 품질 점검 ## 테스트 및 품질 점검

View File

@@ -1,3 +1,3 @@
"""LazyEnter - Slack을 통한 Claude Code 원격 제어 브릿지.""" """LazyEnter - Slack을 통한 Claude/Codex 원격 제어 브릿지."""
__version__ = "0.1.0" __version__ = "0.1.0"

View File

@@ -25,6 +25,7 @@ class Bridge:
self._output_thread: threading.Thread | None = None self._output_thread: threading.Thread | None = None
self._running = False self._running = False
self._channel: str = self.config.allowed_channel_id self._channel: str = self.config.allowed_channel_id
self._active_target: str | None = None
self._last_sent_output: str = "" self._last_sent_output: str = ""
self._last_input_at = time.monotonic() self._last_input_at = time.monotonic()
self._last_output_at = time.monotonic() self._last_output_at = time.monotonic()
@@ -65,15 +66,30 @@ class Bridge:
) )
return any(pattern in normalized for pattern in blocked_patterns) return any(pattern in normalized for pattern in blocked_patterns)
def _handle_command(self, command: str, channel: str) -> None: @staticmethod
def _display_name(target: str) -> str:
if target == "codex":
return "Codex"
return "Claude"
def _session_name_for_target(self, target: str) -> str:
if target == "codex":
return self.config.codex_tmux_session_name
return self.config.tmux_session_name
def _handle_command(self, action: str, target: str, channel: str) -> None:
"""슬래시 커맨드를 처리한다.""" """슬래시 커맨드를 처리한다."""
if command == "start": if target not in {"claude", "codex"}:
self._start_session(channel) self.slack.send_message(channel, ":warning: 지원하지 않는 대상입니다.")
elif command == "stop": return
if action == "start":
self._start_session(channel, target)
elif action == "stop":
self._stop_session(channel) self._stop_session(channel)
def _start_session(self, channel: str) -> None: def _start_session(self, channel: str, target: str) -> None:
"""기존 Claude tmux 세션에 연결한다.""" """지정한 대상의 tmux 세션에 연결한다."""
if self.pty and self.pty.is_alive: if self.pty and self.pty.is_alive:
self.slack.send_message( self.slack.send_message(
channel, ":information_source: 이미 세션에 연결되어 있습니다." channel, ":information_source: 이미 세션에 연결되어 있습니다."
@@ -81,8 +97,12 @@ class Bridge:
return return
self._channel = channel self._channel = channel
self._active_target = target
self._last_sent_output = "" self._last_sent_output = ""
self.pty = PtyManager(self.config.tmux_session_name) self.pty = PtyManager(
self._session_name_for_target(target),
cli_name=target,
)
try: try:
self.pty.start() self.pty.start()
except RuntimeError as exc: except RuntimeError as exc:
@@ -98,7 +118,10 @@ class Bridge:
self._output_thread = threading.Thread(target=self._poll_output, daemon=True) self._output_thread = threading.Thread(target=self._poll_output, daemon=True)
self._output_thread.start() self._output_thread.start()
self.slack.send_message(channel, ":link: Claude 세션에 연결되었습니다.") display_name = self._display_name(target)
self.slack.send_message(
channel, f":link: {display_name} 세션에 연결되었습니다."
)
def _stop_session(self, channel: str) -> None: def _stop_session(self, channel: str) -> None:
"""브릿지 연결만 해제한다.""" """브릿지 연결만 해제한다."""
@@ -106,6 +129,7 @@ class Bridge:
if self.pty: if self.pty:
self.pty.stop() self.pty.stop()
self.pty = None self.pty = None
self._active_target = None
self._last_sent_output = "" self._last_sent_output = ""
self.slack.send_message(channel, ":electric_plug: 세션 연결이 해제되었습니다.") self.slack.send_message(channel, ":electric_plug: 세션 연결이 해제되었습니다.")

View File

@@ -20,6 +20,7 @@ class Config:
# PTY / tmux attach # PTY / tmux attach
tmux_session_name: str = os.getenv("TMUX_SESSION_NAME", "claude") tmux_session_name: str = os.getenv("TMUX_SESSION_NAME", "claude")
codex_tmux_session_name: str = os.getenv("CODEX_TMUX_SESSION_NAME", "codex")
pty_read_timeout: int = int(os.getenv("PTY_READ_TIMEOUT", "5")) pty_read_timeout: int = int(os.getenv("PTY_READ_TIMEOUT", "5"))
# Buffer # Buffer

View File

@@ -13,8 +13,9 @@ logger = logging.getLogger(__name__)
class PtyManager: class PtyManager:
"""기존 tmux 세션에 attach하고 입출력을 제어한다.""" """기존 tmux 세션에 attach하고 입출력을 제어한다."""
def __init__(self, session_name: str = "claude") -> None: def __init__(self, session_name: str = "claude", cli_name: str = "claude") -> None:
self.session_name = session_name self.session_name = session_name
self.cli_name = cli_name
self._process: pexpect.spawn | None = None self._process: pexpect.spawn | None = None
@property @property
@@ -32,7 +33,7 @@ class PtyManager:
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError( raise RuntimeError(
"tmux 세션이 없습니다. 먼저 실행하세요: " "tmux 세션이 없습니다. 먼저 실행하세요: "
f"tmux new -s {self.session_name} claude" f"tmux new -s {self.session_name} {self.cli_name}"
) )
def start(self) -> None: def start(self) -> None:

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import logging import logging
import time import time
from collections.abc import Callable
from slack_bolt import App from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler from slack_bolt.adapter.socket_mode import SocketModeHandler
@@ -22,8 +23,8 @@ class SlackHandler:
self.app = App(token=config.slack_bot_token) self.app = App(token=config.slack_bot_token)
self._handler: SocketModeHandler | None = None self._handler: SocketModeHandler | None = None
self._stop_requested = False self._stop_requested = False
self._on_message_callback: callable | None = None self._on_message_callback: Callable[[str, str], None] | None = None
self._on_command_callback: callable | None = None self._on_command_callback: Callable[[str, str, str], None] | None = None
self._register_listeners() self._register_listeners()
@@ -46,7 +47,7 @@ class SlackHandler:
"""Slack 이벤트 리스너를 등록한다.""" """Slack 이벤트 리스너를 등록한다."""
@self.app.event("message") @self.app.event("message")
def handle_message(event: dict, say: callable) -> None: def handle_message(event: dict, say: Callable[..., None]) -> None:
user_id = event.get("user", "") user_id = event.get("user", "")
channel_id = event.get("channel", "") channel_id = event.get("channel", "")
text = event.get("text", "") text = event.get("text", "")
@@ -58,7 +59,7 @@ class SlackHandler:
self._on_message_callback(text, channel_id) self._on_message_callback(text, channel_id)
@self.app.command("/start-claude") @self.app.command("/start-claude")
def handle_start(ack: callable, body: dict) -> None: def handle_start_claude(ack: Callable[..., None], body: dict) -> None:
ack() ack()
user_id = body.get("user_id", "") user_id = body.get("user_id", "")
channel_id = body.get("channel_id", "") channel_id = body.get("channel_id", "")
@@ -67,10 +68,10 @@ class SlackHandler:
return return
if self._on_command_callback: if self._on_command_callback:
self._on_command_callback("start", channel_id) self._on_command_callback("start", "claude", channel_id)
@self.app.command("/stop-claude") @self.app.command("/stop-claude")
def handle_stop(ack: callable, body: dict) -> None: def handle_stop_claude(ack: Callable[..., None], body: dict) -> None:
ack() ack()
user_id = body.get("user_id", "") user_id = body.get("user_id", "")
channel_id = body.get("channel_id", "") channel_id = body.get("channel_id", "")
@@ -79,13 +80,37 @@ class SlackHandler:
return return
if self._on_command_callback: if self._on_command_callback:
self._on_command_callback("stop", channel_id) self._on_command_callback("stop", "claude", channel_id)
def on_message(self, callback: callable) -> None: @self.app.command("/start-codex")
def handle_start_codex(ack: Callable[..., None], body: dict) -> None:
ack()
user_id = body.get("user_id", "")
channel_id = body.get("channel_id", "")
if not self._is_authorized(user_id, channel_id):
return
if self._on_command_callback:
self._on_command_callback("start", "codex", channel_id)
@self.app.command("/stop-codex")
def handle_stop_codex(ack: Callable[..., None], body: dict) -> None:
ack()
user_id = body.get("user_id", "")
channel_id = body.get("channel_id", "")
if not self._is_authorized(user_id, channel_id):
return
if self._on_command_callback:
self._on_command_callback("stop", "codex", channel_id)
def on_message(self, callback: Callable[[str, str], None]) -> None:
"""메시지 수신 콜백을 등록한다.""" """메시지 수신 콜백을 등록한다."""
self._on_message_callback = callback self._on_message_callback = callback
def on_command(self, callback: callable) -> None: def on_command(self, callback: Callable[[str, str, str], None]) -> None:
"""슬래시 커맨드 콜백을 등록한다.""" """슬래시 커맨드 콜백을 등록한다."""
self._on_command_callback = callback self._on_command_callback = callback

115
tests/test_bridge.py Normal file
View File

@@ -0,0 +1,115 @@
"""Bridge 명령 라우팅 테스트."""
from __future__ import annotations
import sys
import types
slack_bolt = types.ModuleType("slack_bolt")
slack_bolt.App = object
socket_mode = types.ModuleType("slack_bolt.adapter.socket_mode")
socket_mode.SocketModeHandler = object
slack_sdk = types.ModuleType("slack_sdk")
slack_sdk.WebClient = object
sys.modules.setdefault("slack_bolt", slack_bolt)
sys.modules.setdefault("slack_bolt.adapter.socket_mode", socket_mode)
sys.modules.setdefault("slack_sdk", slack_sdk)
class FakeSlackHandler:
def __init__(self, _config) -> None:
self.message_callback = None
self.command_callback = None
self.sent_messages: list[tuple[str, str]] = []
def on_message(self, callback) -> None:
self.message_callback = callback
def on_command(self, callback) -> None:
self.command_callback = callback
def send_message(self, channel: str, text: str) -> None:
self.sent_messages.append((channel, text))
def start(self) -> None:
return None
def stop(self) -> None:
return None
class FakePtyManager:
instances: list[FakePtyManager] = []
def __init__(self, session_name: str, cli_name: str = "claude") -> None:
self.session_name = session_name
self.cli_name = cli_name
self._alive = False
FakePtyManager.instances.append(self)
@property
def is_alive(self) -> bool:
return self._alive
def start(self) -> None:
self._alive = True
def stop(self) -> None:
self._alive = False
def send(self, _text: str) -> None:
return None
def read_output(self, timeout: int = 5) -> str:
return ""
def _make_bridge(monkeypatch):
from lazy_enter.bridge import Bridge
from lazy_enter.config import Config
monkeypatch.setattr("lazy_enter.bridge.SlackHandler", FakeSlackHandler)
monkeypatch.setattr("lazy_enter.bridge.PtyManager", FakePtyManager)
config = Config()
config.tmux_session_name = "claude-room"
config.codex_tmux_session_name = "codex-room"
return Bridge(config)
def test_start_claude_routes_to_claude_session(monkeypatch) -> None:
FakePtyManager.instances.clear()
bridge = _make_bridge(monkeypatch)
bridge._handle_command("start", "claude", "C1")
assert FakePtyManager.instances[-1].session_name == "claude-room"
assert FakePtyManager.instances[-1].cli_name == "claude"
assert bridge.slack.sent_messages[-1] == (
"C1",
":link: Claude 세션에 연결되었습니다.",
)
def test_start_codex_routes_to_codex_session(monkeypatch) -> None:
FakePtyManager.instances.clear()
bridge = _make_bridge(monkeypatch)
bridge._handle_command("start", "codex", "C1")
assert FakePtyManager.instances[-1].session_name == "codex-room"
assert FakePtyManager.instances[-1].cli_name == "codex"
assert bridge.slack.sent_messages[-1] == (
"C1",
":link: Codex 세션에 연결되었습니다.",
)
def test_unknown_target_is_rejected(monkeypatch) -> None:
bridge = _make_bridge(monkeypatch)
bridge._handle_command("start", "unknown", "C1")
assert bridge.slack.sent_messages[-1] == (
"C1",
":warning: 지원하지 않는 대상입니다.",
)

View File

@@ -8,6 +8,7 @@ from lazy_enter.config import Config
def test_config_defaults(): def test_config_defaults():
config = Config() config = Config()
assert config.tmux_session_name == "claude" assert config.tmux_session_name == "claude"
assert config.codex_tmux_session_name == "codex"
assert config.pty_read_timeout == 5 assert config.pty_read_timeout == 5
assert config.output_buffer_interval == 2.0 assert config.output_buffer_interval == 2.0
assert config.max_message_length == 3000 assert config.max_message_length == 3000