@cliphijack/santaclaude 1.0.23 → 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 +54 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cliphijack/santaclaude",
3
- "version": "1.0.23",
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
@@ -52,9 +52,11 @@ function checkUpdate() {
52
52
  const target = String(j.target || '');
53
53
  if (!target || target === VER) return; // 목표=현재면 그대로 (target≠VER이면 업/다운 둘다 = 롤백 가능)
54
54
  if (process.env.SC_SUP === '1') {
55
- console.log(` ⬆️ 새 버전 ${target} (현재 ${VER}) — 업데이트 재시작…`);
55
+ console.log(` ⬆️ 새 버전 ${target} (현재 ${VER}) — 미리 받는 중(무중단 교체)…`);
56
56
  try { fs.writeFileSync(_TGT, target); } catch (e) {}
57
- setTimeout(() => process.exit(75), 800);
57
+ // 🔄 프리페치: 교체 전에 새 버전을 npx 캐시에 미리 받아둔다(status는 ping 한 번 하고 종료=데몬 안 띄움) → 재시작 순간 빠름
58
+ try { require('child_process').spawn('npx', ['-y', '@cliphijack/santaclaude@' + target, 'status'], { stdio: 'ignore', detached: true }).unref(); } catch (e) {}
59
+ setTimeout(() => { console.log(' 🔄 교체 — 1~2초 끊겼다 복귀(루돌프·예약은 계속, 빠진 잡은 서버가 보관)'); process.exit(75); }, 5000);
58
60
  } else console.log(` ⬆️ 새 버전 ${target} 나옴 (현재 ${VER}) — 재시작 권장: npx -y @cliphijack/santaclaude@latest`);
59
61
  }).catch(() => {});
60
62
  }
@@ -257,6 +259,45 @@ function collectTok24h() {
257
259
  let LEAGUE_ON = false, _tokCache = 0, _tokAt = 0;
258
260
  function tok24h() { const now = Date.now(); if (LEAGUE_ON && now - _tokAt > 5 * 60 * 1000) { _tokCache = collectTok24h(); _tokAt = now; } return LEAGUE_ON ? _tokCache : 0; }
259
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
+
260
301
  async function post(api, p, body) {
261
302
  const r = await fetch(api + p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
262
303
  return r.json();
@@ -378,6 +419,7 @@ async function run(conf) {
378
419
  cleanOldImages();
379
420
  try { execFileSync('tmux', ['set-option', '-g', 'history-limit', '50000'], { stdio: 'ignore' }); } catch (e) {} // 새로 띄우는 루돌프 pane은 스크롤백 5만줄(기존 pane은 유지)
380
421
  setTimeout(() => { try { fs.writeFileSync(_GOOD, VER); } catch (e) {} }, 60000); // 60초+ 살면 이 버전을 안정버전으로 기록(롤백 기준)
422
+ refreshTn(); setInterval(refreshTn, 30000); // 📡 테일스케일·Taildrop 감지(30초)
381
423
  setTimeout(checkUpdate, 90000); setInterval(checkUpdate, 3 * 3600 * 1000); // 자동 업데이트 체크: 시작 90초 후 + 3시간마다
382
424
  console.log(`🛷 SantaClaude 커넥터 가동 v${VER} — pane=${pane} · ${every / 1000}s 폴링 · ${api}`);
383
425
  if (paneExists(pane)) {
@@ -435,6 +477,14 @@ async function run(conf) {
435
477
  setTimeout(() => { try { execFileSync('tmux', ['send-keys', '-t', w, 'Enter']); } catch (e) {} }, 300);
436
478
  return;
437
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
+ }
438
488
  if (cmd.action === 'image') {
439
489
  // 웹이 올린 파일을 받아 로컬에 저장하고 claude에 경로 주입 (이미지·txt·mp3·mp4 등 전부)
440
490
  const dir = path.join(os.homedir(), '.santaclaude', 'img');
@@ -612,7 +662,7 @@ async function run(conf) {
612
662
  console.log(`[발사] ${new Date().toISOString()} → ${tgt}: ${String(j.message).slice(0, 60)}`);
613
663
  try { inject(tgt, '[SantaClaude] ' + j.message); } catch (e) { console.warn(` 주입 실패(${tgt}): ${e.message}`); }
614
664
  }
615
- 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) {} } }
616
666
  // 화면 미러 — 변경됐을 때만 전송(claude 멈추면 0). 2.5초 체크라 응답 직후 빠르게 반영
617
667
  let lastScr = '';
618
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) {} }
@@ -638,7 +688,7 @@ async function run(conf) {
638
688
  // 폴링 폴백 (node<21 = WebSocket 미지원) — 워커가 DO 백엔드라 폴링이어도 KV write 0
639
689
  async function pollTick() {
640
690
  try {
641
- 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(() => {});
642
692
  post(api, '/api/control/claim', { token }).then((c) => { for (const cmd of (c && c.commands) || []) runControl(cmd); }).catch(() => {});
643
693
  const d = await post(api, '/api/jobs/claim', { token });
644
694
  for (const j of (d.jobs || [])) onJob(j);