Files
openclaw/workspace/skills/claude-code-session/scripts/session_tool.py
T
hyowons 549545bde6 Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적.
시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)·
백업(clobbered/bak)·dream 캐시는 .gitignore로 제외.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:10:57 +09:00

445 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""Claude Code Remote Control 세션 관리 툴 (다중 세션 + 프로필 CRUD).
데이터 모델:
profile: 이름 ↔ workdir 매핑. `~/.openclaw/state/claude_sessions.json`에 저장.
session: profile 기반으로 떠있는 클로드 데몬. plist 파일 존재 여부가 곧 세션 등록.
라벨 `ai.claude-session.YYMMDD-<N>` (N은 날짜별 1부터 빈 번호 채움).
서브커맨드:
profile list
profile add <name> <workdir>
profile remove <name>
profile edit <name> <new-workdir>
session open [profile=openclaw]
session close <session-name>
session list
session status <session-name>
레거시 `ai.openclaw.claude-remote-control`는 본 툴이 건드리지 않음 (기존
ensure_session.sh가 계속 관리). `session list`에서 참고용으로만 노출.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
from zoneinfo import ZoneInfo
HOME = Path.home()
OPENCLAW_ROOT = HOME / '.openclaw'
STATE_DIR = OPENCLAW_ROOT / 'state'
STATE_FILE = STATE_DIR / 'claude_sessions.json'
LOG_DIR = OPENCLAW_ROOT / 'logs'
LA_DIR = HOME / 'Library' / 'LaunchAgents'
CLAUDE_BIN = Path(os.environ.get('CLAUDE_BIN', HOME / '.local' / 'bin' / 'claude'))
LABEL_PREFIX = 'ai.claude-session.'
LEGACY_LABEL = 'ai.openclaw.claude-remote-control'
DEFAULT_PROFILE = 'openclaw'
DEFAULT_PROFILE_WORKDIR = OPENCLAW_ROOT
NAME_RE = re.compile(r'^[a-z][a-z0-9_-]*$')
SESSION_RE = re.compile(
r'^(?:(?P<date_compact>\d{6})|'
r'(?P<profile_compact>[a-z][a-z0-9_-]*?)-(?P<date_compact_profile>\d{6})|'
r'(?P<profile_dash>[a-z][a-z0-9_-]*?)-(?P<date_dash>\d{2}-\d{2}-\d{2})|'
r'(?P<profile_dated>[a-z][a-z0-9_-]*?)-(?P<date8>\d{8})|'
r'(?P<profile_legacy>[a-z][a-z0-9_-]*))-(?P<n>[1-9]\d*)$'
)
# ───────── state I/O ─────────
def _load_state() -> dict:
if not STATE_FILE.exists():
return {'profiles': {DEFAULT_PROFILE: {'workdir': str(DEFAULT_PROFILE_WORKDIR)}}}
try:
data = json.loads(STATE_FILE.read_text())
except json.JSONDecodeError as e:
raise SystemExit(f'state 파일 파싱 실패: {STATE_FILE} ({e})')
data.setdefault('profiles', {})
if DEFAULT_PROFILE not in data['profiles']:
data['profiles'][DEFAULT_PROFILE] = {'workdir': str(DEFAULT_PROFILE_WORKDIR)}
return data
def _save_state(data: dict) -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
tmp = STATE_FILE.with_suffix('.json.tmp')
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
tmp.replace(STATE_FILE)
def _validate_name(name: str, kind: str = '이름') -> None:
if not NAME_RE.match(name):
raise SystemExit(f'{kind} 형식 오류: {name!r} (소문자·숫자·`_`·`-`만, 첫 글자는 소문자)')
def _resolve_workdir(workdir: str) -> str:
p = Path(workdir).expanduser()
if not p.is_dir():
raise SystemExit(f'workdir 없음 또는 디렉터리 아님: {p}')
return str(p.resolve())
# ───────── launchctl wrappers ─────────
def _uid() -> int:
return os.getuid()
def _today() -> str:
try:
return datetime.now(ZoneInfo('Asia/Seoul')).strftime('%y%m%d')
except Exception:
return datetime.now().strftime('%y%m%d')
def _session_name(profile: str, n: int, date: str | None = None) -> str:
if date:
if profile == DEFAULT_PROFILE:
return f'{date}-{n}'
return f'{profile}-{date}-{n}'
return f'{profile}-{n}'
def _label(session_name: str) -> str:
return f'{LABEL_PREFIX}{session_name}'
def _plist_path(session_name: str) -> Path:
return LA_DIR / f'{_label(session_name)}.plist'
def _service(label: str) -> str:
return f'gui/{_uid()}/{label}'
def _launchctl_print(label: str) -> Optional[str]:
p = subprocess.run(['launchctl', 'print', _service(label)], capture_output=True, text=True)
if p.returncode != 0:
return None
return p.stdout
def _is_running(label: str) -> bool:
out = _launchctl_print(label)
if not out:
return False
for line in out.splitlines():
m = re.match(r'\s*state\s*=\s*(\S+)', line)
if m:
return m.group(1) == 'running'
return False
def _bootstrap(plist: Path) -> None:
subprocess.run(['launchctl', 'bootstrap', f'gui/{_uid()}', str(plist)], check=True)
def _bootout(label: str) -> None:
subprocess.run(['launchctl', 'bootout', _service(label)], capture_output=True)
# bootout이 비동기적이라 잠깐 라벨이 graveyard에 남는다.
# `launchctl print`가 실패할 때까지 짧게 폴링해서 후속 bootstrap 충돌 방지.
import time
for _ in range(20): # 최대 ~2초
if _launchctl_print(label) is None:
return
time.sleep(0.1)
def _kickstart(label: str) -> None:
subprocess.run(['launchctl', 'kickstart', _service(label)], capture_output=True)
# ───────── plist 생성 ─────────
PLIST_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
<key>ProgramArguments</key>
<array>
<string>{claude_bin}</string>
<string>remote-control</string>
<string>--name</string>
<string>{session_name}</string>
</array>
<key>WorkingDirectory</key>
<string>{workdir}</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>{path_env}</string>
<key>HOME</key>
<string>{home}</string>
</dict>
<key>StandardOutPath</key>
<string>{stdout}</string>
<key>StandardErrorPath</key>
<string>{stderr}</string>
<key>RunAtLoad</key>
<false/>
<key>KeepAlive</key>
<false/>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
'''
def _write_plist(session_name: str, workdir: str) -> Path:
LA_DIR.mkdir(parents=True, exist_ok=True)
LOG_DIR.mkdir(parents=True, exist_ok=True)
label = _label(session_name)
path = _plist_path(session_name)
path.write_text(PLIST_TEMPLATE.format(
label=label,
claude_bin=CLAUDE_BIN,
session_name=session_name,
workdir=workdir,
path_env=f'{HOME}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin',
home=HOME,
stdout=LOG_DIR / f'claude-session-{session_name}.log',
stderr=LOG_DIR / f'claude-session-{session_name}.err.log',
))
return path
# ───────── 활성 세션 스캔 ─────────
def _list_active_sessions() -> list[dict]:
"""LaunchAgents 폴더 스캔. 본 툴이 만든 plist만."""
out = []
if not LA_DIR.exists():
return out
for plist in sorted(LA_DIR.glob(f'{LABEL_PREFIX}*.plist')):
label = plist.stem
suffix = label[len(LABEL_PREFIX):]
m = SESSION_RE.match(suffix)
if not m:
continue
profile = (
m.group('profile_compact') or m.group('profile_dash') or
m.group('profile_dated') or m.group('profile_legacy') or DEFAULT_PROFILE
)
if profile == 'oc':
profile = DEFAULT_PROFILE
date = (
m.group('date_compact') or m.group('date_compact_profile') or
m.group('date_dash') or m.group('date8')
)
n = int(m.group('n'))
running = _is_running(label)
workdir = _read_plist_workdir(plist)
out.append({
'session': suffix,
'profile': profile,
'date': date,
'n': n,
'workdir': workdir,
'running': running,
'plist': plist,
'label': label,
})
return out
def _read_plist_workdir(plist: Path) -> str:
p = subprocess.run(['/usr/libexec/PlistBuddy', '-c', 'Print :WorkingDirectory', str(plist)],
capture_output=True, text=True)
return p.stdout.strip() if p.returncode == 0 else '?'
def _legacy_running() -> bool:
return _is_running(LEGACY_LABEL)
def _next_free_number(profile: str, date: str) -> int:
used = {s['n'] for s in _list_active_sessions() if s['profile'] == profile and s.get('date') == date}
n = 1
while n in used:
n += 1
return n
# ───────── 커맨드 핸들러 ─────────
def cmd_profile_list(args) -> int:
state = _load_state()
print(f'{"NAME":<16} WORKDIR')
for name, prof in sorted(state['profiles'].items()):
print(f'{name:<16} {prof["workdir"]}')
return 0
def cmd_profile_add(args) -> int:
_validate_name(args.name, '프로필 이름')
workdir = _resolve_workdir(args.workdir)
state = _load_state()
if args.name in state['profiles']:
raise SystemExit(f"이미 등록된 프로필: {args.name}{state['profiles'][args.name]['workdir']}")
state['profiles'][args.name] = {'workdir': workdir}
_save_state(state)
print(f'프로필 추가됨: {args.name}{workdir}')
return 0
def cmd_profile_remove(args) -> int:
state = _load_state()
if args.name not in state['profiles']:
raise SystemExit(f'등록되지 않은 프로필: {args.name}')
if args.name == DEFAULT_PROFILE:
raise SystemExit(f"기본 프로필 '{DEFAULT_PROFILE}'은 삭제 불가.")
active = [s for s in _list_active_sessions() if s['profile'] == args.name]
if active:
names = ', '.join(s['session'] for s in active)
raise SystemExit(f'활성 세션이 있어 삭제 불가: {names} (먼저 close)')
del state['profiles'][args.name]
_save_state(state)
print(f'프로필 삭제됨: {args.name}')
return 0
def cmd_profile_edit(args) -> int:
state = _load_state()
if args.name not in state['profiles']:
raise SystemExit(f'등록되지 않은 프로필: {args.name}')
workdir = _resolve_workdir(args.workdir)
active = [s for s in _list_active_sessions() if s['profile'] == args.name]
if active:
names = ', '.join(s['session'] for s in active)
raise SystemExit(f'활성 세션이 있어 변경 불가: {names} (먼저 close)')
state['profiles'][args.name]['workdir'] = workdir
_save_state(state)
print(f'프로필 수정됨: {args.name}{workdir}')
return 0
def cmd_session_open(args) -> int:
profile = args.profile or DEFAULT_PROFILE
_validate_name(profile, '프로필 이름')
state = _load_state()
prof = state['profiles'].get(profile)
if not prof:
raise SystemExit(f'등록되지 않은 프로필: {profile} (먼저 `profile add` 실행)')
workdir = prof['workdir']
if not Path(workdir).is_dir():
raise SystemExit(f'프로필 workdir 없음: {workdir}')
date = _today()
n = _next_free_number(profile, date)
session_name = _session_name(profile, n, date)
label = _label(session_name)
plist = _write_plist(session_name, workdir)
try:
_bootstrap(plist)
except subprocess.CalledProcessError as e:
plist.unlink(missing_ok=True)
raise SystemExit(f'launchctl bootstrap 실패: {e}')
_kickstart(label)
import time
time.sleep(2)
if _is_running(label):
print(f'세션 시작됨: {session_name} (workdir={workdir})')
print(f"claude.ai/code 또는 Claude 앱에서 '{session_name}' 클릭하여 접속하세요.")
return 0
print(f'WARN: {session_name} bootstrap은 됐는데 running 확인 실패. 로그: {LOG_DIR}/claude-session-{session_name}.err.log',
file=sys.stderr)
return 1
def cmd_session_close(args) -> int:
target = args.name
m = SESSION_RE.match(target)
if not m:
raise SystemExit(f"세션 이름 형식 오류: {target} (예: 260520-1)")
label = _label(target)
plist = _plist_path(target)
if not plist.exists() and not _launchctl_print(label):
raise SystemExit(f'세션 없음: {target}')
_bootout(label)
plist.unlink(missing_ok=True)
print(f'세션 종료됨: {target}')
return 0
def cmd_session_list(args) -> int:
sessions = _list_active_sessions()
print(f'{"SESSION":<24} {"STATE":<8} WORKDIR')
legacy_state = 'running' if _legacy_running() else 'stopped'
print(f'{"openclaw (legacy)":<24} {legacy_state:<8} {OPENCLAW_ROOT}')
for s in sessions:
state = 'running' if s['running'] else 'stopped'
print(f'{s["session"]:<24} {state:<8} {s["workdir"]}')
return 0
def cmd_session_status(args) -> int:
target = args.name
m = SESSION_RE.match(target)
if not m:
raise SystemExit(f"세션 이름 형식 오류: {target} (예: 260520-1)")
label = _label(target)
if _is_running(label):
print(f'{target} 동작 중')
return 0
if _plist_path(target).exists():
print(f'{target} 등록됐으나 정지 상태')
return 1
print(f'{target} 없음')
return 2
# ───────── argparse ─────────
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog='session_tool', description=__doc__.splitlines()[0] if __doc__ else None)
sub = p.add_subparsers(dest='group', required=True)
pp = sub.add_parser('profile', help='프로필(이름↔workdir) CRUD')
psub = pp.add_subparsers(dest='cmd', required=True)
psub.add_parser('list', help='등록된 프로필 출력').set_defaults(func=cmd_profile_list)
pa = psub.add_parser('add', help='프로필 추가')
pa.add_argument('name'); pa.add_argument('workdir'); pa.set_defaults(func=cmd_profile_add)
pr = psub.add_parser('remove', help='프로필 삭제 (활성 세션 없을 때만)')
pr.add_argument('name'); pr.set_defaults(func=cmd_profile_remove)
pe = psub.add_parser('edit', help='프로필 workdir 변경 (활성 세션 없을 때만)')
pe.add_argument('name'); pe.add_argument('workdir'); pe.set_defaults(func=cmd_profile_edit)
sp = sub.add_parser('session', help='세션 (실제 떠있는 클로드 인스턴스) 관리')
ssub = sp.add_subparsers(dest='cmd', required=True)
so = ssub.add_parser('open', help='세션 열기 (빈 번호 자동 할당)')
so.add_argument('profile', nargs='?'); so.set_defaults(func=cmd_session_open)
sc = ssub.add_parser('close', help='세션 종료')
sc.add_argument('name'); sc.set_defaults(func=cmd_session_close)
ssub.add_parser('list', help='활성 세션 목록').set_defaults(func=cmd_session_list)
ss = ssub.add_parser('status', help='세션 상태')
ss.add_argument('name'); ss.set_defaults(func=cmd_session_status)
return p
def main(argv: list[str]) -> int:
parser = _build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))