@cliphijack/santaclaude 1.0.13 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/santaclaude.js +72 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cliphijack/santaclaude",
3
- "version": "1.0.13",
3
+ "version": "1.0.16",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "SantaClaude 커넥터 — 클라우드 예약을 내 로컬 Claude(tmux)에 발사",
6
6
  "bin": { "santaclaude": "./santaclaude.js" },
package/santaclaude.js CHANGED
@@ -80,6 +80,35 @@ function captureWindow(session, name) {
80
80
  try { return execFileSync('tmux', ['capture-pane', '-t', session + ':' + name, '-p', '-e', '-S', '-500'], { encoding: 'utf8' }).replace(/\n+$/, '').slice(-60000); }
81
81
  catch (e) { return ''; }
82
82
  }
83
+ // 🔍 로컬 스킬 발견 — ~/.claude/skills + 각 루돌프 프로젝트 .claude/skills 스캔 (등록 누를 때만 1회)
84
+ function parseFrontmatter(md) {
85
+ const m = String(md).match(/^\s*---\s*\n([\s\S]*?)\n---/); let name = '', desc = '';
86
+ if (m) { const nm = m[1].match(/^name:\s*(.+)$/m); const dm = m[1].match(/^description:\s*(.+)$/m); if (nm) name = nm[1].trim(); if (dm) desc = dm[1].trim(); }
87
+ return { name, desc };
88
+ }
89
+ function scanSkillDir(dir, scope, sessionName, out, seen) {
90
+ let entries = []; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
91
+ for (const e of entries) {
92
+ if (!e.isDirectory()) continue;
93
+ const sd = path.join(dir, e.name);
94
+ let md = ''; try { md = fs.readFileSync(path.join(sd, 'SKILL.md'), 'utf8'); } catch (err) { continue; } // SKILL.md 없으면 스킬 아님
95
+ const fm = parseFrontmatter(md);
96
+ const name = String(fm.name || e.name).slice(0, 80); const key = name.toLowerCase();
97
+ if (seen.has(key)) continue; seen.add(key);
98
+ out.push({ name, desc: String(fm.desc || '').slice(0, 200), scope, dir: sd, session: sessionName || '' });
99
+ }
100
+ }
101
+ function collectLocalSkills(session) {
102
+ const out = [], seen = new Set();
103
+ scanSkillDir(path.join(os.homedir(), '.claude', 'skills'), '전역 (~/.claude/skills)', '', out, seen);
104
+ try {
105
+ for (const w of listWindows(session)) {
106
+ let cwd = ''; try { cwd = execFileSync('tmux', ['display-message', '-t', session + ':' + w, '-p', '#{pane_current_path}'], { encoding: 'utf8' }).trim(); } catch (e) {}
107
+ if (cwd && cwd !== os.homedir()) scanSkillDir(path.join(cwd, '.claude', 'skills'), '프로젝트: ' + w, w, out, seen);
108
+ }
109
+ } catch (e) {}
110
+ return out.slice(0, 200);
111
+ }
83
112
  function captureAll(session) {
84
113
  const o = {}; for (const w of listWindows(session)) o[w] = captureWindow(session, w); return o;
85
114
  }
@@ -158,6 +187,32 @@ function linesToTurns(lines, withImgs) {
158
187
  function parseTurns(file, maxTurns) {
159
188
  return linesToTurns(readTail(file, 2 * 1024 * 1024), false).slice(-Math.max(10, Math.min(200, maxTurns || 60)));
160
189
  }
190
+ // 🔥 열일 루돌프 — 최근 24h 토큰 합산(로컬 JSONL의 usage). opt-in(리그 등판) 때만 보고. 숫자만, 대화내용 0
191
+ function collectTok24h() {
192
+ const cutoff = Date.now() - 24 * 3600 * 1000;
193
+ let files = [];
194
+ try {
195
+ const base = path.join(os.homedir(), '.claude', 'projects'); if (!fs.existsSync(base)) return 0;
196
+ for (const d of fs.readdirSync(base)) {
197
+ const dp = path.join(base, d); try { if (!fs.statSync(dp).isDirectory()) continue; } catch (e) { continue; }
198
+ for (const f of fs.readdirSync(dp)) { if (!f.endsWith('.jsonl')) continue; const fp = path.join(dp, f); try { if (fs.statSync(fp).mtimeMs > cutoff) files.push({ fp, m: fs.statSync(fp).mtimeMs }); } catch (e) {} }
199
+ }
200
+ } catch (e) { return 0; }
201
+ files.sort((a, b) => b.m - a.m); files = files.slice(0, 25); // 최근 수정 25개만(부담 방지)
202
+ let tok = 0;
203
+ for (const { fp } of files) {
204
+ for (const line of readTail(fp, 6 * 1024 * 1024)) {
205
+ if (!line) continue; let o; try { o = JSON.parse(line); } catch (e) { continue; }
206
+ if (o.type !== 'assistant') continue;
207
+ const ts = o.timestamp ? Date.parse(o.timestamp) : 0; if (ts && ts < cutoff) continue;
208
+ const u = o.message && o.message.usage; if (!u) continue;
209
+ tok += (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
210
+ }
211
+ }
212
+ return tok;
213
+ }
214
+ let LEAGUE_ON = false, _tokCache = 0, _tokAt = 0;
215
+ function tok24h() { const now = Date.now(); if (LEAGUE_ON && now - _tokAt > 5 * 60 * 1000) { _tokCache = collectTok24h(); _tokAt = now; } return LEAGUE_ON ? _tokCache : 0; }
161
216
 
162
217
  async function post(api, p, body) {
163
218
  const r = await fetch(api + p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
@@ -298,6 +353,17 @@ async function run(conf) {
298
353
  // 루돌프 = 작업장(session) 안의 윈도우(탭). 별도 세션 아님.
299
354
  async function runControl(cmd) {
300
355
  try {
356
+ if (cmd.action === 'scan-skills') {
357
+ const skills = collectLocalSkills(session);
358
+ try { await fetch(api + '/api/localskills', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, skills }) }); } catch (e) {}
359
+ console.log(' 🔍 로컬 스킬 ' + skills.length + '개 발견 → 보고');
360
+ return;
361
+ }
362
+ if (cmd.action === 'league') { // 🔥 열일 루돌프 리그 opt-in 토글 (켜질 때만 24h 토큰 보고)
363
+ LEAGUE_ON = !!cmd.on; _tokAt = 0; // 캐시 무효화 → 다음 hb에 즉시 반영
364
+ console.log(' 🔥 열일 리그 등판: ' + (LEAGUE_ON ? 'ON' : 'OFF'));
365
+ return;
366
+ }
301
367
  if (cmd.action === 'skill') {
302
368
  // 마켓 스킬 이식 — 그 루돌프 프로젝트 폴더 .claude/skills/<name>/ 에 설치 + claude에 적용 주입
303
369
  const w = (cmd.name && windowExists(session, cmd.name)) ? (session + ':' + cmd.name) : pane;
@@ -377,15 +443,15 @@ async function run(conf) {
377
443
  execFileSync('tmux', ['send-keys', '-t', w, String(cmd.key || '')]);
378
444
  console.log(` ⌨️ 키 → ${w}: ${cmd.key}`);
379
445
  } else if (cmd.action === 'resize') {
380
- // 뷰어 폭에 맞춰 tmux 윈도우 리사이즈 claude TUI가 그 폭으로 reflow(모바일 가로스크롤 해소)
381
- // 보통 cols만 옴 = 폭만 맞추고 높이는 그대로(스크롤백으로 세로 내역 풍부하게). rows 오면 같이 적용.
382
- if (clientsAttached(session)) { console.log(' 📐 리사이즈 건너뜀 — 사람이 tmux에 붙어있음(고객 화면 보호)'); return; } // 사람이 직접 보고있으면 reflow로 화면 망가뜨리지 않음
446
+ // 📐 창에맞춤 토글: fit=강제 reflow(사람 붙어있어도) / restore=window-size latest로 원복( 터미널 따라가기)
383
447
  const w = (cmd.name && windowExists(session, cmd.name)) ? (session + ':' + cmd.name) : pane;
448
+ if (cmd.restore) { try { execFileSync('tmux', ['set-window-option', '-t', w, 'window-size', 'latest']); console.log(' 📐 창에맞춤 OFF → 터미널 크기로 복원(window-size latest)'); } catch (e) {} return; }
449
+ if (!cmd.fit && clientsAttached(session)) { console.log(' 📐 리사이즈 스킵(사람 attach + 창맞춤 off=보호)'); return; } // fit OFF + 사람 붙음 = 보호
384
450
  const c = parseInt(cmd.cols) || 0, rr = parseInt(cmd.rows) || 0;
385
451
  const args = ['resize-window', '-t', w];
386
452
  if (c > 0) args.push('-x', String(Math.max(20, Math.min(400, c))));
387
453
  if (rr > 0) args.push('-y', String(Math.max(10, Math.min(200, rr))));
388
- if (args.length > 3) { try { execFileSync('tmux', args); console.log(` 📐 리사이즈 ${w} → 폭${c || '그대로'}${rr ? '×높이' + rr : ''}`); } catch (e) {} }
454
+ if (args.length > 3) { try { execFileSync('tmux', args); console.log(` 📐 리사이즈 ${w} → 폭${c || '그대로'}${rr ? '×' + rr : ''}${cmd.fit ? ' (창맞춤)' : ''}`); } catch (e) {} }
389
455
  } else if (cmd.action === 'transcript') {
390
456
  if (cmd.live) { txState.until = Date.now() + 45000; return; } // keepalive — 라이브 모드만 연장(seed 안 함, 초경량)
391
457
  // 💬 seed: 전체내역(JSONL 꼬리) 읽어 웹에 전달 + 라이브 모드 무장(이후 델타는 화면틱에 묻어감)
@@ -493,7 +559,7 @@ async function run(conf) {
493
559
  console.log(`[발사] ${new Date().toISOString()} → ${tgt}: ${String(j.message).slice(0, 60)}`);
494
560
  try { inject(tgt, '[SantaClaude] ' + j.message); } catch (e) { console.warn(` 주입 실패(${tgt}): ${e.message}`); }
495
561
  }
496
- function sendHb() { if (ws && ws.readyState === 1) { try { ws.send(JSON.stringify({ type: 'hb', pane, sessions: listWindows(session) })); } catch (e) {} } }
562
+ function sendHb() { if (ws && ws.readyState === 1) { try { ws.send(JSON.stringify({ type: 'hb', pane, sessions: listWindows(session), token: LEAGUE_ON ? token : undefined, tok: LEAGUE_ON ? tok24h() : undefined })); } catch (e) {} } }
497
563
  // 화면 미러 — 변경됐을 때만 전송(claude 멈추면 0). 2.5초 체크라 응답 직후 빠르게 반영
498
564
  let lastScr = '';
499
565
  function sendScreen() { if (!ws || ws.readyState !== 1) return; let cur; try { cur = JSON.stringify(captureAll(session)); } catch (e) { return; } if (cur === lastScr) return; lastScr = cur; try { ws.send(JSON.stringify({ type: 'screen', screens: JSON.parse(cur) })); } catch (e) {} }
@@ -519,7 +585,7 @@ async function run(conf) {
519
585
  // 폴링 폴백 (node<21 = WebSocket 미지원) — 워커가 DO 백엔드라 폴링이어도 KV write 0
520
586
  async function pollTick() {
521
587
  try {
522
- post(api, '/api/heartbeat', { token, pane, sessions: listWindows(session), screens: captureAll(session) }).catch(() => {});
588
+ post(api, '/api/heartbeat', { token, pane, sessions: listWindows(session), screens: captureAll(session), tok: LEAGUE_ON ? tok24h() : undefined }).catch(() => {});
523
589
  post(api, '/api/control/claim', { token }).then((c) => { for (const cmd of (c && c.commands) || []) runControl(cmd); }).catch(() => {});
524
590
  const d = await post(api, '/api/jobs/claim', { token });
525
591
  for (const j of (d.jobs || [])) onJob(j);