@cliphijack/santaclaude 1.0.24 → 1.0.25

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 +50 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cliphijack/santaclaude",
3
- "version": "1.0.24",
3
+ "version": "1.0.25",
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();
@@ -380,6 +419,7 @@ async function run(conf) {
380
419
  cleanOldImages();
381
420
  try { execFileSync('tmux', ['set-option', '-g', 'history-limit', '50000'], { stdio: 'ignore' }); } catch (e) {} // 새로 띄우는 루돌프 pane은 스크롤백 5만줄(기존 pane은 유지)
382
421
  setTimeout(() => { try { fs.writeFileSync(_GOOD, VER); } catch (e) {} }, 60000); // 60초+ 살면 이 버전을 안정버전으로 기록(롤백 기준)
422
+ refreshTn(); setInterval(refreshTn, 30000); // 📡 테일스케일·Taildrop 감지(30초)
383
423
  setTimeout(checkUpdate, 90000); setInterval(checkUpdate, 3 * 3600 * 1000); // 자동 업데이트 체크: 시작 90초 후 + 3시간마다
384
424
  console.log(`🛷 SantaClaude 커넥터 가동 v${VER} — pane=${pane} · ${every / 1000}s 폴링 · ${api}`);
385
425
  if (paneExists(pane)) {
@@ -437,6 +477,14 @@ async function run(conf) {
437
477
  setTimeout(() => { try { execFileSync('tmux', ['send-keys', '-t', w, 'Enter']); } catch (e) {} }, 300);
438
478
  return;
439
479
  }
480
+ if (cmd.action === 'taildrop') { // 📲 PC→폰 Taildrop 푸시 (C). 공유폴더 파일을 지정 피어로
481
+ const fn = path.basename(String(cmd.file || '')); const peer = String(cmd.peer || '').replace(/[^A-Za-z0-9._-]/g, '');
482
+ if (!fn || !peer) return;
483
+ 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) {} }
484
+ if (!fp) { console.log(' 📲 Taildrop: 파일 못 찾음 — ' + fn); return; }
485
+ try { execFileSync('tailscale', ['file', 'cp', fp, peer + ':'], { timeout: 120000 }); console.log(` 📲 Taildrop → ${peer}: ${fn}`); } catch (e) { console.log(' 📲 Taildrop 실패: ' + (e.message || '')); }
486
+ return;
487
+ }
440
488
  if (cmd.action === 'image') {
441
489
  // 웹이 올린 파일을 받아 로컬에 저장하고 claude에 경로 주입 (이미지·txt·mp3·mp4 등 전부)
442
490
  const dir = path.join(os.homedir(), '.santaclaude', 'img');
@@ -614,7 +662,7 @@ async function run(conf) {
614
662
  console.log(`[발사] ${new Date().toISOString()} → ${tgt}: ${String(j.message).slice(0, 60)}`);
615
663
  try { inject(tgt, '[SantaClaude] ' + j.message); } catch (e) { console.warn(` 주입 실패(${tgt}): ${e.message}`); }
616
664
  }
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) {} } }
665
+ 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
666
  // 화면 미러 — 변경됐을 때만 전송(claude 멈추면 0). 2.5초 체크라 응답 직후 빠르게 반영
619
667
  let lastScr = '';
620
668
  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 +688,7 @@ async function run(conf) {
640
688
  // 폴링 폴백 (node<21 = WebSocket 미지원) — 워커가 DO 백엔드라 폴링이어도 KV write 0
641
689
  async function pollTick() {
642
690
  try {
643
- post(api, '/api/heartbeat', { token, pane, sessions: listWindows(session), screens: captureAll(session), tok: LEAGUE_ON ? tok24h() : undefined }).catch(() => {});
691
+ post(api, '/api/heartbeat', { token, pane, sessions: listWindows(session), screens: captureAll(session), tok: LEAGUE_ON ? tok24h() : undefined, tn: _tnCache }).catch(() => {});
644
692
  post(api, '/api/control/claim', { token }).then((c) => { for (const cmd of (c && c.commands) || []) runControl(cmd); }).catch(() => {});
645
693
  const d = await post(api, '/api/jobs/claim', { token });
646
694
  for (const j of (d.jobs || [])) onJob(j);