@cliphijack/santaclaude 1.0.24 → 1.0.26

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 +68 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cliphijack/santaclaude",
3
- "version": "1.0.24",
3
+ "version": "1.0.26",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "SantaClaude 커넥터 — 클라우드 예약을 내 로컬 Claude(tmux)에 발사",
6
6
  "bin": { "santaclaude": "./santaclaude.js" },
package/santaclaude.js CHANGED
@@ -259,6 +259,45 @@ function collectTok24h() {
259
259
  let LEAGUE_ON = false, _tokCache = 0, _tokAt = 0;
260
260
  function tok24h() { const now = Date.now(); if (LEAGUE_ON && now - _tokAt > 5 * 60 * 1000) { _tokCache = collectTok24h(); _tokAt = now; } return LEAGUE_ON ? _tokCache : 0; }
261
261
 
262
+ // 📡 테일스케일 — 18MB 우회. A)미니 파일서버(PC→폰 직다운) C)Taildrop 푸시(PC→폰) + 받은파일(폰→PC). 비동기 캐시
263
+ const SHARE_PORT = 8799;
264
+ const SHARE_DIRS = [path.join(os.homedir(), '.santaclaude', 'outbox'), path.join(os.homedir(), '.santaclaude', 'share'), path.join(os.homedir(), 'Downloads')];
265
+ let _tnCache = null, _shareToken = null, _shareServer = null;
266
+ function startShareServer(ip) { // 테일넷 IP에만 바인드 → 테일넷 피어만 접근. 경로 토큰 + basename으로 보호
267
+ if (_shareServer || !ip) return;
268
+ try { _shareToken = require('crypto').randomBytes(8).toString('hex'); } catch (e) { _shareToken = String(Date.now()); }
269
+ const http = require('http');
270
+ _shareServer = http.createServer((req, res) => {
271
+ try {
272
+ const parts = decodeURIComponent((req.url || '').split('?')[0]).split('/').filter(Boolean);
273
+ if (parts[0] !== _shareToken) { res.writeHead(403); return res.end('no'); }
274
+ const fname = path.basename(parts.slice(1).join('/')); if (!fname) { res.writeHead(404); return res.end('no'); }
275
+ for (const d of SHARE_DIRS) { const fp = path.join(d, fname); if (fp.startsWith(d)) { try { if (fs.statSync(fp).isFile()) { res.writeHead(200, { 'Content-Disposition': 'attachment; filename="' + encodeURIComponent(fname) + '"', 'Content-Length': fs.statSync(fp).size, 'Content-Type': 'application/octet-stream' }); return fs.createReadStream(fp).pipe(res); } } catch (x) {} } }
276
+ res.writeHead(404); res.end('no');
277
+ } catch (e) { try { res.writeHead(500); res.end(); } catch (x) {} }
278
+ });
279
+ _shareServer.on('error', () => { try { _shareServer.close(); } catch (x) {} _shareServer = null; });
280
+ try { _shareServer.listen(SHARE_PORT, ip); } catch (e) { _shareServer = null; }
281
+ }
282
+ function refreshTn() {
283
+ const { execFile } = require('child_process');
284
+ execFile('tailscale', ['ip', '-4'], { timeout: 4000 }, (e, out) => {
285
+ if (e || !out) { _tnCache = null; return; }
286
+ const ip = String(out).trim().split('\n')[0]; if (!ip) { _tnCache = null; return; }
287
+ let host = ''; try { host = String(execFileSync('hostname', [], { encoding: 'utf8' })).trim(); } catch (x) {}
288
+ startShareServer(ip);
289
+ execFile('tailscale', ['status', '--json'], { timeout: 4000, maxBuffer: 8e6 }, (e2, sout) => {
290
+ let peers = []; try { const j = JSON.parse(sout || '{}'); peers = Object.values(j.Peer || {}).map((p) => ({ name: p.HostName || (p.DNSName || '').split('.')[0] || '', target: (p.DNSName || p.HostName || '').replace(/\.$/, ''), os: p.OS || '' })).filter((p) => p.name).slice(0, 25); } catch (x) {}
291
+ execFile('tailscale', ['file', 'get', '--conflict=rename', path.join(os.homedir(), 'Downloads')], { timeout: 6000 }, () => { // Linux: 대기중 Taildrop 받기(best-effort)
292
+ const drops = [], dir = path.join(os.homedir(), 'Downloads'), now = Date.now();
293
+ try { for (const f of fs.readdirSync(dir)) { if (f.startsWith('.')) continue; const fp = path.join(dir, f); try { const st = fs.statSync(fp); if (st.isFile() && now - st.mtimeMs < 25 * 3600 * 1000) drops.push({ name: f.slice(0, 120), size: st.size, mt: Math.round(st.mtimeMs) }); } catch (x) {} } } catch (x) {}
294
+ drops.sort((a, b) => b.mt - a.mt);
295
+ _tnCache = { ip, host, port: SHARE_PORT, token: _shareToken, peers, drops: drops.slice(0, 15) };
296
+ });
297
+ });
298
+ });
299
+ }
300
+
262
301
  async function post(api, p, body) {
263
302
  const r = await fetch(api + p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
264
303
  return r.json();
@@ -317,7 +356,7 @@ description: 산타클로드 파일 보내기 — 만든·가진 파일(이미
317
356
 
318
357
  ## 규칙
319
358
  - **파일명 그대로 유지**(확장자 포함) — 사용자가 그 이름으로 받는다.
320
- - **18MB 이하**만 지금 가능. 초과 파일은 보내고 알림이 뜬다 영상은 잘라 보내거나 외부 링크로.
359
+ - **크기 제한 사실상 없음** — 18MB 이하는 자동 릴레이, 파일(영상 등)은 테일스케일이 켜져 있으면 **테일넷 직다운(📡)으로 자동 전환**(폰이 같은 테일넷이면 한도 없이 받음). 테일스케일이 없을 때만 18MB 초과 불가.
321
360
  - **원본 그대로 복사** — 재인코딩·리사이즈·압축 금지.
322
361
  - 옮기지 말고 **복사**(cp). 원본 작업물은 그대로 둔다.
323
362
  - 보낸 뒤 사용자에게 "보냈어 — <파일명>" 한 줄 확인.
@@ -360,9 +399,11 @@ function installDefaultSkills(cwd) {
360
399
  for (const [fn, content] of Object.entries(files)) {
361
400
  try {
362
401
  const fp = path.join(base, '.claude', 'skills', name, fn);
363
- if (fs.existsSync(fp)) continue; // 이미 있으면 덮지 않음(고객 수정 보존)
402
+ const existed = fs.existsSync(fp);
403
+ if (existed) { try { if (fs.readFileSync(fp, 'utf8') === content) continue; } catch (e) {} } // santa-* 는 시스템 스킬 — 내용 다르면(구버전) 갱신, 같으면 스킵
364
404
  fs.mkdirSync(path.dirname(fp), { recursive: true });
365
405
  fs.writeFileSync(fp, content);
406
+ if (existed) console.log(` 🔄 기본 스킬 갱신: ${name}`);
366
407
  } catch (e) {}
367
408
  }
368
409
  }
@@ -380,6 +421,7 @@ async function run(conf) {
380
421
  cleanOldImages();
381
422
  try { execFileSync('tmux', ['set-option', '-g', 'history-limit', '50000'], { stdio: 'ignore' }); } catch (e) {} // 새로 띄우는 루돌프 pane은 스크롤백 5만줄(기존 pane은 유지)
382
423
  setTimeout(() => { try { fs.writeFileSync(_GOOD, VER); } catch (e) {} }, 60000); // 60초+ 살면 이 버전을 안정버전으로 기록(롤백 기준)
424
+ refreshTn(); setInterval(refreshTn, 30000); // 📡 테일스케일·Taildrop 감지(30초)
383
425
  setTimeout(checkUpdate, 90000); setInterval(checkUpdate, 3 * 3600 * 1000); // 자동 업데이트 체크: 시작 90초 후 + 3시간마다
384
426
  console.log(`🛷 SantaClaude 커넥터 가동 v${VER} — pane=${pane} · ${every / 1000}s 폴링 · ${api}`);
385
427
  if (paneExists(pane)) {
@@ -396,7 +438,8 @@ async function run(conf) {
396
438
  console.warn(` ⚠️ tmux "${session}" 세션 없음 (--no-spawn) — 직접 띄워줘.`);
397
439
  }
398
440
  // 기본 스킬 자동 설치 (메인 루돌프 작업 폴더에)
399
- try { let c = os.homedir(); try { const pc = execFileSync('tmux', ['display-message', '-t', pane, '-p', '#{pane_current_path}'], { encoding: 'utf8' }).trim(); if (pc) c = pc; } catch (e) {} installDefaultSkills(c); } catch (e) {}
441
+ try { installDefaultSkills(os.homedir()); } catch (e) {} // 글로벌(~/.claude/skills) 기본스킬 설치·갱신 모든 세션 공용
442
+ try { let c = ''; try { const pc = execFileSync('tmux', ['display-message', '-t', pane, '-p', '#{pane_current_path}'], { encoding: 'utf8' }).trim(); if (pc && pc !== os.homedir()) c = pc; } catch (e) {} if (c) installDefaultSkills(c); } catch (e) {}
400
443
 
401
444
  // 루돌프 = 작업장(session) 안의 윈도우(탭). 별도 세션 아님.
402
445
  async function runControl(cmd) {
@@ -437,6 +480,14 @@ async function run(conf) {
437
480
  setTimeout(() => { try { execFileSync('tmux', ['send-keys', '-t', w, 'Enter']); } catch (e) {} }, 300);
438
481
  return;
439
482
  }
483
+ if (cmd.action === 'taildrop') { // 📲 PC→폰 Taildrop 푸시 (C). 공유폴더 파일을 지정 피어로
484
+ const fn = path.basename(String(cmd.file || '')); const peer = String(cmd.peer || '').replace(/[^A-Za-z0-9._-]/g, '');
485
+ if (!fn || !peer) return;
486
+ let fp = ''; for (const d of SHARE_DIRS) { const c = path.join(d, fn); try { if (fs.statSync(c).isFile()) { fp = c; break; } } catch (e) {} }
487
+ if (!fp) { console.log(' 📲 Taildrop: 파일 못 찾음 — ' + fn); return; }
488
+ try { execFileSync('tailscale', ['file', 'cp', fp, peer + ':'], { timeout: 120000 }); console.log(` 📲 Taildrop → ${peer}: ${fn}`); } catch (e) { console.log(' 📲 Taildrop 실패: ' + (e.message || '')); }
489
+ return;
490
+ }
440
491
  if (cmd.action === 'image') {
441
492
  // 웹이 올린 파일을 받아 로컬에 저장하고 claude에 경로 주입 (이미지·txt·mp3·mp4 등 전부)
442
493
  const dir = path.join(os.homedir(), '.santaclaude', 'img');
@@ -589,7 +640,18 @@ async function run(conf) {
589
640
  const fp = path.join(OUTBOXDIR, fn);
590
641
  let st; try { st = fs.statSync(fp); } catch (e) { continue; }
591
642
  if (!st.isFile()) continue;
592
- if (st.size > MAXOUT) { console.warn(` ⚠️ "${fn}" ${(st.size / 1048576).toFixed(1)}MB > 18MB 못 보냄(R2 대기)`); try { fs.renameSync(fp, fp + '.toobig'); } catch (e) {} continue; }
643
+ if (st.size > MAXOUT) { // 18MB 초과 테일스케일 있으면 share/로 옮겨 미니서버 직다운(📡), 없으면 보류
644
+ if (_tnCache && _tnCache.ip) {
645
+ try {
646
+ const shareDir = path.join(os.homedir(), '.santaclaude', 'share'); fs.mkdirSync(shareDir, { recursive: true });
647
+ fs.renameSync(fp, path.join(shareDir, fn)); // outbox 밖으로 → 재스캔 안 됨, 미니서버가 서빙
648
+ const mime = EXT_MIME[(fn.split('.').pop() || '').toLowerCase()] || 'application/octet-stream';
649
+ await post(api, '/api/outfile', { token, name: fn, mime, size: st.size, tn: true }).catch(() => {});
650
+ console.log(` 📡 "${fn}" ${(st.size / 1048576).toFixed(1)}MB → 테일넷 직다운(share/)`);
651
+ } catch (e) { console.warn(' big→테일넷 실패: ' + e.message); }
652
+ } else { console.warn(` ⚠️ "${fn}" ${(st.size / 1048576).toFixed(1)}MB > 18MB · 테일스케일 없어 못 보냄`); try { fs.renameSync(fp, fp + '.toobig'); } catch (e) {} }
653
+ continue;
654
+ }
593
655
  const ext = (fn.split('.').pop() || '').toLowerCase();
594
656
  const mime = EXT_MIME[ext] || 'application/octet-stream';
595
657
  let data; try { data = fs.readFileSync(fp).toString('base64'); } catch (e) { continue; }
@@ -614,7 +676,7 @@ async function run(conf) {
614
676
  console.log(`[발사] ${new Date().toISOString()} → ${tgt}: ${String(j.message).slice(0, 60)}`);
615
677
  try { inject(tgt, '[SantaClaude] ' + j.message); } catch (e) { console.warn(` 주입 실패(${tgt}): ${e.message}`); }
616
678
  }
617
- 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) {} } }
679
+ 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, tn: _tnCache })); } catch (e) {} } }
618
680
  // 화면 미러 — 변경됐을 때만 전송(claude 멈추면 0). 2.5초 체크라 응답 직후 빠르게 반영
619
681
  let lastScr = '';
620
682
  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) {} }
@@ -640,7 +702,7 @@ async function run(conf) {
640
702
  // 폴링 폴백 (node<21 = WebSocket 미지원) — 워커가 DO 백엔드라 폴링이어도 KV write 0
641
703
  async function pollTick() {
642
704
  try {
643
- post(api, '/api/heartbeat', { token, pane, sessions: listWindows(session), screens: captureAll(session), tok: LEAGUE_ON ? tok24h() : undefined }).catch(() => {});
705
+ post(api, '/api/heartbeat', { token, pane, sessions: listWindows(session), screens: captureAll(session), tok: LEAGUE_ON ? tok24h() : undefined, tn: _tnCache }).catch(() => {});
644
706
  post(api, '/api/control/claim', { token }).then((c) => { for (const cmd of (c && c.commands) || []) runControl(cmd); }).catch(() => {});
645
707
  const d = await post(api, '/api/jobs/claim', { token });
646
708
  for (const j of (d.jobs || [])) onJob(j);