@cliphijack/santaclaude 1.0.11 → 1.0.12

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 +31 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cliphijack/santaclaude",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "SantaClaude 커넥터 — 클라우드 예약을 내 로컬 Claude(tmux)에 발사",
6
6
  "bin": { "santaclaude": "./santaclaude.js" },
package/santaclaude.js CHANGED
@@ -115,10 +115,19 @@ function readTail(file, maxBytes) {
115
115
  return s.split('\n');
116
116
  } catch (e) { return []; }
117
117
  }
118
- // JSONL user/assistant (툴콜=칩, 첨부이미지=data URL). 파일 꼬리만 읽고 최근 maxTurns개.
119
- function parseTurns(file, maxTurns) {
118
+ // 오프셋 이후 새로 늘어난 줄만 읽음(라이브 델타) 완전한 줄까지만, 다음 오프셋 반환
119
+ function readDelta(file, from) {
120
+ try {
121
+ const size = fs.statSync(file).size; if (size <= from) return { lines: [], off: from };
122
+ const len = size - from; const fd = fs.openSync(file, 'r'); const buf = Buffer.alloc(len); fs.readSync(fd, buf, 0, len, from); fs.closeSync(fd);
123
+ const s = buf.toString('utf8'); const lastNl = s.lastIndexOf('\n'); if (lastNl < 0) return { lines: [], off: from }; // 완전한 줄 없음 → 다음 틱에
124
+ const complete = s.slice(0, lastNl);
125
+ return { lines: complete.split('\n'), off: from + Buffer.byteLength(complete, 'utf8') + 1 };
126
+ } catch (e) { return { lines: [], off: from }; }
127
+ }
128
+ // JSONL 줄들 → user/assistant 턴 (툴콜=칩, 첨부이미지=data URL)
129
+ function linesToTurns(lines) {
120
130
  const out = []; let imgBudget = 8 * 1024 * 1024; // 전체 이미지 base64 합계 상한(모바일 보호)
121
- const lines = readTail(file, 2 * 1024 * 1024); // 최근 ~2MB만 (통째로 X) — 형 지적: "꼭 다 열어야되나"
122
131
  function grabImgs(c) { const out2 = []; if (!Array.isArray(c)) return out2; for (const b of c) { if (b && b.type === 'image' && b.source) { const s = b.source; if (s.type === 'base64' && s.data && imgBudget > s.data.length) { imgBudget -= s.data.length; out2.push('data:' + (s.media_type || 'image/png') + ';base64,' + s.data); } else if (s.type === 'url' && s.url) out2.push(String(s.url)); } } return out2; }
123
132
  for (const line of lines) {
124
133
  if (!line) continue; let o; try { o = JSON.parse(line); } catch (e) { continue; }
@@ -143,7 +152,11 @@ function parseTurns(file, maxTurns) {
143
152
  out.push({ role: 'assistant', text: text.slice(0, 8000), tools, imgs });
144
153
  }
145
154
  }
146
- return out.slice(-Math.max(10, Math.min(200, maxTurns || 60)));
155
+ return out;
156
+ }
157
+ // 파일 꼬리(최근 2MB)만 읽어 최근 maxTurns개 (seed용)
158
+ function parseTurns(file, maxTurns) {
159
+ return linesToTurns(readTail(file, 2 * 1024 * 1024)).slice(-Math.max(10, Math.min(200, maxTurns || 60)));
147
160
  }
148
161
 
149
162
  async function post(api, p, body) {
@@ -262,6 +275,7 @@ async function run(conf) {
262
275
  const session = String(pane).split(':')[0];
263
276
  const claudeCmd = conf.claudeCmd || 'claude --dangerously-skip-permissions';
264
277
  let warned = false; // 중복방지는 서버가 책임 (claim 시 next_fire 원자적 전진) — 클라 영구셋은 반복예약을 영구차단하므로 안 둠
278
+ let txState = { until: 0, off: 0, file: null, win: '' }; // 💬 루돌프톡 라이브: 무장시각·읽은 오프셋·세션파일·창
265
279
 
266
280
  cleanOldImages();
267
281
  console.log(`🛷 SantaClaude 커넥터 가동 — pane=${pane} · ${every / 1000}s 폴링 · ${api}`);
@@ -373,14 +387,16 @@ async function run(conf) {
373
387
  if (rr > 0) args.push('-y', String(Math.max(10, Math.min(200, rr))));
374
388
  if (args.length > 3) { try { execFileSync('tmux', args); console.log(` 📐 리사이즈 ${w} → 폭${c || '그대로'}${rr ? '×높이' + rr : ''}`); } catch (e) {} }
375
389
  } else if (cmd.action === 'transcript') {
376
- // 💬 루돌프의 claude 대화 전체내역(JSONL) 읽어 웹에 전달우리 서버엔쌓음(TTL 릴레이)
390
+ if (cmd.live) { txState.until = Date.now() + 45000; return; } // keepalive 라이브 모드만 연장(seed 함, 초경량)
391
+ // 💬 seed: 전체내역(JSONL 꼬리) 읽어 웹에 전달 + 라이브 모드 무장(이후 델타는 화면틱에 묻어감)
377
392
  const w = (cmd.name && windowExists(session, cmd.name)) ? (session + ':' + cmd.name) : pane;
378
393
  let cwd = os.homedir(); try { const pc = execFileSync('tmux', ['display-message', '-t', w, '-p', '#{pane_current_path}'], { encoding: 'utf8' }).trim(); if (pc) cwd = pc; } catch (e) {}
379
394
  const file = findTranscript(cwd);
380
395
  let turns = [], found = !!file;
381
396
  if (file) turns = parseTurns(file, parseInt(cmd.n) || 60);
382
397
  try { await post(api, '/api/transcript', { token, window: cmd.name || '', turns, found }); } catch (e) {}
383
- console.log(` 💬 대화내역 ${turns.length} 웹${found ? '' : ' (트랜스크립트 찾음)'}`);
398
+ txState = { until: Date.now() + 45000, off: file ? (function () { try { return fs.statSync(file).size; } catch (e) { return 0; } })() : 0, file: file, win: cmd.name || '' }; // seed 시점 파일끝=오프셋 기준
399
+ console.log(` 💬 대화내역 ${turns.length}턴 → 웹${found ? ' (라이브 ON)' : ' (트랜스크립트 못 찾음)'}`);
384
400
  }
385
401
  } catch (e) { console.warn(` ⚠️ 제어 실패(${cmd.action} ${cmd.name}): ${e.message}`); }
386
402
  }
@@ -481,13 +497,21 @@ async function run(conf) {
481
497
  // 화면 미러 — 변경됐을 때만 전송(claude 멈추면 0). 2.5초 체크라 응답 직후 빠르게 반영
482
498
  let lastScr = '';
483
499
  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) {} }
500
+ // 💬 루돌프톡 라이브 델타 — 화면틱에 묻어감. 라이브 무장 중 + JSONL 늘었을 때만 새 턴 전송(우리 서버 추가비용 0에 수렴)
501
+ function sendTxDelta() {
502
+ if (!ws || ws.readyState !== 1) return; if (Date.now() > txState.until || !txState.file) return;
503
+ let d; try { d = readDelta(txState.file, txState.off); } catch (e) { return; }
504
+ if (!d.lines.length) return; txState.off = d.off;
505
+ const turns = linesToTurns(d.lines); if (!turns.length) return;
506
+ try { ws.send(JSON.stringify({ type: 'txdelta', window: txState.win, turns })); console.log(` 💬+ 라이브 델타 ${turns.length}턴`); } catch (e) {}
507
+ }
484
508
 
485
509
  // ── WebSocket 상시 연결 (폴링 제거 — DO가 예약/명령을 push) ──
486
510
  const wsUrl = api.replace(/^http/, 'ws') + '/ws?token=' + encodeURIComponent(token);
487
511
  let ws = null, hbIv = null, scrIv = null;
488
512
  function connectWS() {
489
513
  try { ws = new WebSocket(wsUrl); } catch (e) { console.error(' WebSocket 생성 실패:', e.message); setTimeout(connectWS, 5000); return; }
490
- ws.addEventListener('open', () => { console.log(' 🔌 클라우드 연결됨 (WebSocket).'); lastScr = ''; sendHb(); sendScreen(); clearInterval(hbIv); clearInterval(scrIv); hbIv = setInterval(sendHb, Math.max(every, 15000)); scrIv = setInterval(sendScreen, 2500); });
514
+ ws.addEventListener('open', () => { console.log(' 🔌 클라우드 연결됨 (WebSocket).'); lastScr = ''; sendHb(); sendScreen(); clearInterval(hbIv); clearInterval(scrIv); hbIv = setInterval(sendHb, Math.max(every, 15000)); scrIv = setInterval(() => { sendScreen(); sendTxDelta(); }, 2500); });
491
515
  ws.addEventListener('message', (e) => { let m; try { m = JSON.parse(typeof e.data === 'string' ? e.data : e.data.toString()); } catch { return; } if (m.type === 'job') onJob(m); else if (m.type === 'ctl') runControl(m.cmd); });
492
516
  ws.addEventListener('close', () => { clearInterval(hbIv); clearInterval(scrIv); console.log(' 🔌 연결 끊김 — 5초 후 재연결'); setTimeout(connectWS, 5000); });
493
517
  ws.addEventListener('error', () => { try { ws.close(); } catch (e) {} });