auto: 일일 백업 2026-10-08 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-10-08 02:00:06 +09:00
parent 114a1ef816
commit 4f63ec56dc
114 changed files with 1667 additions and 1290 deletions
+17 -11
View File
@@ -46,6 +46,8 @@ DEFAULT_PORT = 18793
AGENTS = [('main', '클로', '🦞'), ('stock', '레이', '🪎'), ('budget', '골디', '📒')]
AGENT_IDS = {a[0] for a in AGENTS}
SESSION_TOOL = ROOT / 'workspace' / 'skills' / 'claude-code-session' / 'scripts' / 'session_tool.py'
# Claude Code 세션 바로가기 (session_tool 프로필 이름, 표시명, 이모지). workdir 은 프로필에서 읽는다
CLAUDE_TARGETS = [('openclaw', '코디', '🛠'), ('develop2025', 'Averno', '🌋')]
SESSION_TTL = 30 * 86400
OTP_TTL = 300
@@ -240,14 +242,17 @@ def _cody_url(name: str) -> str | None:
return m[-1] if m else None
def _open_cody() -> dict:
def _open_cody(profile: str = 'openclaw') -> dict:
if profile not in {t[0] for t in CLAUDE_TARGETS}:
return {'error': '알 수 없는 대상'}
with _cody_lock:
st = _session_tool()
running = [x for x in st._list_active_sessions() if x['running'] and Path(x['workdir']) == ROOT]
workdir = Path(st._load_state()['profiles'][profile]['workdir'])
running = [x for x in st._list_active_sessions() if x['running'] and Path(x['workdir']) == workdir]
if running:
name, reused = running[-1]['session'], True
else:
r = subprocess.run([sys.executable, str(SESSION_TOOL), 'session', 'open', 'openclaw'],
r = subprocess.run([sys.executable, str(SESSION_TOOL), 'session', 'open', profile],
capture_output=True, text=True, timeout=60)
import re
m = re.search(r'세션 시작됨: (\S+)', r.stdout)
@@ -472,18 +477,19 @@ textarea{font-size:13px;padding:7px 10px;border-radius:16px}
def _app_page() -> bytes:
agents = json.dumps([{'id': a, 'name': n, 'emoji': e} for a, n, e in AGENTS], ensure_ascii=False)
targets = json.dumps(CLAUDE_TARGETS, ensure_ascii=False)
body = f'''<header><div class="title" id="title"></div>
<button class="menu-btn" id="menuBtn" aria-label="메뉴">⋯</button>
<div class="menu" id="menu"><button id="statusBtn">상태 보기</button><button id="resetBtn">새 대화 시작</button>
<form method="post" action="/logout" style="margin:0"><button>로그아웃</button></form></div></header>
<main id="log"></main>
<div class="cody-box" id="codyBox"><div class="cody-card"><b>🛠 코디</b><div class="msg2" id="codyMsg"></div>
<div class="cody-box" id="codyBox"><div class="cody-card"><b id="codyTitle"></b><div class="msg2" id="codyMsg"></div>
<a id="codyLink" href="#" target="_blank" rel="noopener">Claude 앱에서 열기</a><button id="codyClose">닫기</button></div></div>
<div class="typing" id="typing"></div>
<footer><div class="agent-pick"><button class="agent-btn" id="agentBtn" aria-label="에이전트 선택"><span id="agentEmoji"></span><span class="dot"></span></button>
<div class="agent-list" id="agentList"></div></div><textarea id="input" rows="1" placeholder="메시지"></textarea>
<button id="send" aria-label="전송">➤</button></footer>
<script>window.AGENTS={agents};</script><script src="/app.js"></script>'''
<script>window.AGENTS={agents};window.CT={targets};</script><script src="/app.js"></script>'''
return _page('채팅', body, APP_CSS)
@@ -495,7 +501,7 @@ if(vv){const fit=()=>{const m=document.getElementById('log'),stick=m&&m.scrollHe
document.body.style.height=vv.height+'px';document.body.style.top=vv.offsetTop+'px';
if(stick)m.scrollTop=m.scrollHeight}; // 맨 아래를 보던 중이면 키보드가 올라와도 마지막 메시지를 가리지 않게
vv.addEventListener('resize',fit);vv.addEventListener('scroll',fit);fit()}
const A=window.AGENTS, $=s=>document.querySelector(s);
const CT=window.CT,A=window.AGENTS, $=s=>document.querySelector(s);
const log=$('#log'), input=$('#input'), send=$('#send'), typing=$('#typing');
let cur=localStorage.getItem('cw_agent')||'main', status={}, lastSeen={}, timer=null;
try{lastSeen=JSON.parse(localStorage.getItem('cw_seen')||'{}')}catch(e){}
@@ -523,7 +529,7 @@ function renderTabs(){
$('#agentEmoji').textContent=ca.emoji;let anyUn=false;
$('#agentList').innerHTML=A.map(a=>{const st=status[a.id]||{};const un=a.id!==cur&&st.last&&st.last!==lastSeen[a.id];if(un)anyUn=true;
return '<button class="agent-item'+(a.id===cur?' on':'')+(un?' unread':'')+'" data-id="'+a.id+'">'+a.emoji+' '+a.name+'<span class="dot"></span><span class="st">'+(st.pending?'입력 중…':'')+'</span></button>'}).join('')
+'<button class="agent-item cody" data-cody="1">🛠 코디<span class="st">Claude 앱</span></button>';
+CT.map((t,i)=>'<button class="agent-item'+(i?'':' cody')+'" data-cody="'+t[0]+'" data-label="'+t[2]+' '+t[1]+'">'+t[2]+' '+t[1]+'<span class="st">Claude 앱</span></button>').join('');
$('#agentBtn').classList.toggle('unread',anyUn);
}
function renderLog(items){
@@ -554,7 +560,7 @@ function doSend(choice){
function autosize(){input.style.height='auto';input.style.height=Math.min(input.scrollHeight,window.innerHeight*0.4)+'px'}
$('#agentBtn').addEventListener('click',e=>{e.stopPropagation();$('#menu').classList.remove('open');$('#agentList').classList.toggle('open')});
$('#agentList').addEventListener('click',e=>{const b=e.target.closest('.agent-item');if(!b)return;e.stopPropagation();$('#agentList').classList.remove('open');
if(b.dataset.cody){openCody();return}
if(b.dataset.cody){openCody(b.dataset.cody,b.dataset.label);return}
if(b.dataset.id!==cur){cur=b.dataset.id;localStorage.setItem('cw_agent',cur);load()}});
send.addEventListener('click',()=>doSend());
log.addEventListener('click',e=>{const b=e.target.closest('.choice');if(b&&!b.disabled)doSend(b.dataset.choice)});
@@ -564,9 +570,9 @@ $('#menuBtn').addEventListener('click',e=>{e.stopPropagation();$('#menu').classL
document.addEventListener('click',()=>{$('#menu').classList.remove('open');$('#agentList').classList.remove('open')});
$('#statusBtn').addEventListener('click',()=>doSend('/status'));
// 코디: 세션을 띄우고(떠 있으면 재사용) 주소를 받아 버튼으로 준다 — iOS 는 사용자가 직접 누른 링크여야 앱으로 넘긴다
function openCody(){const box=$('#codyBox'),msg=$('#codyMsg'),a=$('#codyLink');
function openCody(profile,label){const box=$('#codyBox'),msg=$('#codyMsg'),a=$('#codyLink');$('#codyTitle').textContent=label;
msg.textContent='세션 준비 중… (처음 띄우면 10초쯤 걸립니다)';a.style.display='none';box.classList.add('open');
api('/api/cody',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(d=>{
api('/api/cody',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({profile})}).then(d=>{
if(d.error){msg.textContent=d.error;return}
msg.textContent=(d.reused?'떠 있는 세션':'새 세션')+' '+d.session+(d.url.includes('/session_')?' — 진행 중인 대화로 엽니다':' — 이 환경에서 새 대화를 시작합니다');
a.href=d.url;a.style.display='block'}).catch(()=>{msg.textContent='요청 실패'})}
@@ -736,7 +742,7 @@ class Handler(BaseHTTPRequestHandler):
except Exception:
return self._json({'error': 'bad json'}, 400)
if path == '/api/cody':
return self._json(_open_cody())
return self._json(_open_cody(data.get('profile') or 'openclaw'))
ag = data.get('agent')
if ag not in AGENT_IDS:
return self._json({'error': 'agent'}, 400)