Initial commit: OpenClaw 워크스페이스 버전관리 시작

설정·스크립트·스킬·문서·큐레이션 메모리 추적.
시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)·
백업(clobbered/bak)·dream 캐시는 .gitignore로 제외.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-06-04 15:10:57 +09:00
commit 549545bde6
199 changed files with 49671 additions and 0 deletions
@@ -0,0 +1,54 @@
---
name: claude-code-session
description: Claude Code Remote Control 세션을 다중 인스턴스로 관리. 프로필(이름↔workdir)을 등록해두고 자연어로 세션을 열고 닫고 목록을 본다. Use when the owner says "클로드 세션 열어줘"·"X 세션 열어줘"·"openclaw-2 닫아줘"·"세션 목록"·"프로필 추가/삭제/수정", or whenever a request needs a new Python script / debugging / refactoring that 클로 cannot safely do itself — queue a spec under `~/.openclaw/dev-requests/` then open a session so the owner can connect from a phone and have Claude Code handle it.
---
# Claude Code Remote Control 세션 (다중 세션 + 프로필)
## 데이터 모델
- **프로필**: 이름 ↔ workdir 매핑. `~/.openclaw/state/claude_sessions.json`에 JSON으로 저장. 첫 실행 시 `openclaw → ~/.openclaw` 자동 시드.
- **세션**: 프로필을 기반으로 떠있는 클로드 데몬. 기본 프로필은 라벨 `ai.claude-session.YYMMDD-<N>`로 생성한다. plist 파일 존재 = 등록됨, `launchctl print state=running` = 떠있음. 2026-05-20 이전 구형 `<profile>-<N>`, `openclaw-YYYYMMDD-<N>`, `oc-YY-MM-DD-<N>` 세션도 조회·종료 가능.
- **번호링**: `open` 호출 시 해당 프로필·날짜에서 빈 번호(1부터) 자동 할당. 닫으면 그 번호 재사용 가능.
## 툴
`scripts/session_tool.py` (Python) — 모든 동작은 이 툴 한 개로 처리. 클로는 자연어 요청을 아래 매핑으로 라우팅.
### 자연어 매핑
| 관리자님 발화 | 툴 호출 |
|---|---|
| "클로드 세션 열어줘" / "세션 열어줘" | `session_tool.py session open` (default profile=openclaw, `YYMMDD-N` 자동) |
| "X 세션 열어줘" | `session_tool.py session open X` (프로필 X 등록 전이면 안내, 오늘 날짜 + 빈 번호 자동) |
| "260520-2 닫아줘" / "클로드2 닫아줘" | `session_tool.py session close 260520-2` |
| "세션 목록" / "안 닫힌 세션" | `session_tool.py session list` |
| "260520-1 상태" | `session_tool.py session status 260520-1` |
| "프로필 목록" / "등록된 폴더" | `session_tool.py profile list` |
| "프로필 추가 X /path/to/X" | `session_tool.py profile add X /path/to/X` |
| "프로필 삭제 X" | `session_tool.py profile remove X` (활성 세션 있으면 거부) |
| "프로필 X 폴더 변경 /new/path" | `session_tool.py profile edit X /new/path` (활성 세션 있으면 거부) |
이름 매핑 주의: 관리자님이 "클로드N", "openclawN", "openclaw N" 등 다양하게 부르더라도 내부적으로는 오늘 날짜를 넣은 `YYMMDD-<N>` 형식으로 변환해서 호출 (예: 2026-05-20에 "클로드3" → `260520-3`). 단, 기존 구형 `<profile>-<N>`, `openclaw-YYYYMMDD-<N>`, `oc-YY-MM-DD-<N>` 세션명은 그대로 close/status 가능.
## 운영 메모
- 세션 plist는 ephemeral — `open``~/Library/LaunchAgents/ai.claude-session.YYMMDD-<N>.plist` 생성·bootstrap, `close` 시 bootout·plist 삭제. 따라서 LaunchAgents 폴더 스캔이 곧 활성 세션 목록.
- 모든 세션 `RunAtLoad=false`, `KeepAlive=false` (on-demand). 자동 종료 옵션은 미구현 (`claude remote-control` 본체가 idle timeout 미지원). 작업 끝나면 `close` 권장.
- 로그: `~/.openclaw/logs/claude-session-<session-name>.{log,err.log}`
## 레거시 세션과의 관계
- 기존 `ai.openclaw.claude-remote-control` (라벨: `openclaw`, plist: `ai.openclaw.claude-remote-control.plist`)는 본 툴이 건드리지 않음 — `ensure_session.sh`가 그대로 관리.
- `session_tool.py session list`에는 참고용으로 `openclaw (legacy)` 줄이 함께 표시됨 (running/stopped 상태만).
- 새로 다중 세션 운영하려면 본 툴(`session_tool.py`)만 쓰면 충분. 레거시 세션은 단일 세션으로 유지하거나 `ensure_session.sh close`로 정리해도 무방.
## 보안
- Remote Control 접속은 관리자님 Claude 구독 계정 인증 필수 — 외부망 노출 자동 차단
- 세션 workdir 안의 자격증명은 그 세션을 통해 모두 노출 가능 — 외부 프로젝트 디렉터리를 프로필로 등록할 때 그 디렉터리에 민감 자료가 있는지 확인 후 등록
- 작업 끝나면 `close` 권장 (분실·탈취 시 노출 창 최소화)
## 개발 요청 큐와의 연계
추가 진입점 신규 .py 작성·디버깅·리팩토링이 필요한 요청이 들어왔을 때(클로 본인이 직접 처리하면 검증·디버깅 사이클이 부족해 위험): 명세를 `~/.openclaw/dev-requests/<id>.md`에 큐잉한 뒤 `session open openclaw`로 정비공 세션을 띄우고, 관리자님께 "폰/웹에서 접속해서 dev-requests 처리 부탁드립니다" 안내. 또한 응답 안내 시 `~/.openclaw/dev-requests/`에 미처리 명세가 있으면 "대기 중인 개발 요청 N건" 함께 안내.
@@ -0,0 +1,70 @@
#!/bin/bash
# Claude Code Remote Control on-demand controller (클로 스킬용)
#
# 사용법:
# ensure_session.sh open — Remote Control daemon 시작 (이미 떠있으면 안내만)
# ensure_session.sh close — daemon 종료
# ensure_session.sh status — 현재 상태
#
# launchd 등록 라벨: ai.openclaw.claude-remote-control (RunAtLoad=false, KeepAlive=false)
set -euo pipefail
LABEL="ai.openclaw.claude-remote-control"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
SERVICE="gui/$UID/$LABEL"
bootstrap_if_needed() {
if ! launchctl print "$SERVICE" >/dev/null 2>&1; then
if [ ! -f "$PLIST" ]; then
echo "ERROR: plist 없음: $PLIST"
exit 1
fi
launchctl bootstrap "gui/$UID" "$PLIST"
fi
}
is_running() {
local state
state=$(launchctl print "$SERVICE" 2>/dev/null | awk -F'=' '/^[[:space:]]*state[[:space:]]*=/{gsub(/[[:space:]]/,"",$2);print $2;exit}')
[ "$state" = "running" ]
}
cmd="${1:-status}"
case "$cmd" in
open|start)
bootstrap_if_needed
if is_running; then
echo "이미 동작 중. claude.ai/code 또는 Claude 앱에서 'openclaw' 세션에 접속하세요."
else
launchctl kickstart "$SERVICE" >/dev/null 2>&1 || true
sleep 2
if is_running; then
echo "Claude Code 원격 세션 시작됨. claude.ai/code 또는 Claude 앱에서 'openclaw' 세션에 접속하세요."
else
echo "ERROR: 시작 실패. 로그: ~/.openclaw/logs/claude-remote-control.err.log"
exit 1
fi
fi
;;
close|stop)
if launchctl print "$SERVICE" >/dev/null 2>&1; then
launchctl bootout "$SERVICE" 2>/dev/null || true
echo "Claude Code 원격 세션 종료됨."
else
echo "이미 종료 상태."
fi
;;
status)
if launchctl print "$SERVICE" >/dev/null 2>&1 && is_running; then
echo "동작 중. claude.ai/code 또는 Claude 앱에서 'openclaw' 세션 접속 가능."
else
echo "종료 상태. 'open' 명령으로 시작 가능."
fi
;;
*)
echo "Usage: $0 {open|close|status}"
exit 1
;;
esac
@@ -0,0 +1,444 @@
#!/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:]))