shellbase 0.13.5 → 0.13.6

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.
package/dist/agent.js CHANGED
@@ -11,7 +11,7 @@ const { Terminal: HeadlessTerminal } = requireCjs('@xterm/headless');
11
11
  import { requireClient } from './auth.js';
12
12
  import { TERMINAL_CATEGORY, CONTROL_TOKEN, SESSION_ENV } from './config.js';
13
13
  import { startSleepGuard, stopSleepGuard } from './sleep-guard.js';
14
- import { encryptFrame, decryptFrame, generateFrameKey } from './crypto.js';
14
+ import { encryptFrame, encryptBytes, decryptFrame, generateFrameKey, compress } from './crypto.js';
15
15
  import { registerDevice, heartbeatDevice, unregisterDevice, listOnlineNames, renameDevice, markSessionDone, keepDevice, } from './devices.js';
16
16
  import { promptApproval } from './prompt.js';
17
17
  import { touchRecentDir } from './recent-dirs.js';
@@ -39,16 +39,25 @@ const RESTORE_SCROLLBACK_LINES = 300;
39
39
  // (빌드 로그·Claude Code 전체 다시 그리기 등) 프레임이 한도를 넘어 통째로 거부되고, 그 세션은 그 뒤로
40
40
  // 아무 화면도 못 받는다. 폰에는 "검은 화면에 글자 하나 없는" 상태로 보인다 (제보).
41
41
  //
42
- // 기준은 "글자 수" 아니라 UTF-8 **바이트 수** 다. 예전엔 12288 글자로 잘랐는데, 한글은 한 글자가
43
- // 3바이트라 같은 12288 글자라도 실제 크기가 3배까지 달라져서 어떤 때는 너무 크고(거부 위험) 어떤 때는
44
- // 필요 이상으로 잘게 쪼개졌다(조각 수가 늘수록 느리고 유실 확률도 올라간다).
45
- // 40KB 암호화(base64) 55KB 로, 64KB 한도에 아직 여유가 있다. 이 크기면 화면 복원
46
- // (300줄 기준 35KB 안팎)이 **대개 번에** 담긴다 조각이 둘 이상이면 "지우기" 만 먼저
47
- // 도착하고 내용이 뒤따라오는 사이가 그대로 검은 화면으로 보이므로, 조각으로 끝내는 가장 좋다.
48
- const FRAME_BUDGET_BYTES = 40 * 1024;
42
+ // 조각은 적을수록 좋다. 조각이 이상이면 "화면 지우기" 먼저 도착하고 내용이 뒤따라오는
43
+ // 사이가 그대로 검은 화면으로 보이고, 유실될 확률도 조각 수만큼 늘어난다.
44
+ //
45
+ // 기준은 **실제로 통로에 실리는 크기**다. 예전에는 원본을 정해진 바이트 수(40KB)로 자르고
46
+ // "암호화하면 55KB쯤 되겠지" 하고 짐작했는데, 압축이 들어오면 짐작이 아예 성립하지 않는다
47
+ // (얼마나 줄어들지 미리 없다). 짐작이 크면 한도를 넘어 거부되고(=검은 화면), 작으면
48
+ // 쓸데없이 잘게 쪼개진다(=느려짐). 그래서 아래 packOutput 이 압축·암호화까지 실제로 해보고
49
+ // 이 값과 재본다 — 짐작할 일이 없어지고, 압축이 잘 되는 화면은 통째로 한 조각에 담긴다.
50
+ // 64KB 한도에서 JSON 겉포장(kind/to/seq 등) 몫을 빼고 여유를 둔 값.
51
+ const WIRE_BUDGET_BYTES = 56 * 1024;
49
52
  // 서버가 재는 기준이 예상과 다를 수도 있으니, "너무 크다" 고 거부당하면 스스로 절반씩 줄인다.
50
53
  // 이 아래로는 줄이지 않는다 (더 줄여도 안 되면 크기 문제가 아니다).
51
- const MIN_FRAME_BUDGET_BYTES = 8 * 1024;
54
+ const MIN_WIRE_BUDGET_BYTES = 8 * 1024;
55
+ // 이보다 작은 조각은 압축하지 않는다. gzip 은 머리말만 20바이트쯤 되므로 타자 한 글자가
56
+ // 되비치는 것(수십 바이트)까지 압축하면 오히려 커진다. 큰 화면에서만 이득을 챙기면 된다.
57
+ const GZIP_MIN_BYTES = 512;
58
+ // 조각이 한도를 넘을 때 반으로 잘라 다시 재보는 횟수 상한 (2^12 = 4096 조각이면 어떤 경우도 넘친다).
59
+ // 상한이 없으면 이상한 입력에서 영영 쪼개고 있을 수 있다.
60
+ const MAX_PACK_DEPTH = 12;
52
61
  // 전송이 막혀 밀린 출력이 이만큼을 넘으면, 밀린 것을 하나하나 따라 보내는 대신 지금 화면을 통째로
53
62
  // 다시 보낸다 — 사용자가 보게 될 최종 화면은 어차피 같고, 훨씬 빨리 따라잡는다.
54
63
  const MAX_PENDING_OUTPUT = 256 * 1024;
@@ -75,12 +84,29 @@ const PENALTY_INTERVAL_MS = 250;
75
84
  //
76
85
  // 그래서 고정값을 버리고 **실제 왕복 시간을 재서 스스로 조절**한다. 잘 나가면 조금씩 좁히고,
77
86
  // 실패하면 크게 벌린다(AIMD). 세션이 하나면 예전만큼 빠르고, 여러 개여도 스스로 안전한 값을 찾는다.
78
- const CHANNEL_GAP_MIN_MS = 12;
87
+ //
88
+ // 그런데 "간격 하나"로만 조절하는 것에도 한계가 있었다. 12ms 는 **초당 83건** 인데 서버 한도가
89
+ // 초당 100건이라, 세션 하나가 띄엄띄엄 보낼 때는 남아돌지만 세션 셋이 쉬지 않고 보내면 그 83건이
90
+ // 그대로 꽉 찬 상태로 계속된다 — 한도 코앞에 붙어서 달리는 셈이라 조금만 흔들려도 거부당했다.
91
+ // 반대로 타자를 칠 때는 12ms 조차 아깝다(글자 하나가 나가는 데 그만큼 밀린다).
92
+ //
93
+ // 그래서 "**몰아 보내기 여유 + 꾸준한 속도**" 두 가지로 나눠 잡는다(토큰 버킷).
94
+ // · 조용하다가 갑자기 보낼 때는 여유분만큼 **간격 없이** 곧바로 나간다 (타자 체감이 좋아진다)
95
+ // · 그 여유를 다 쓰면 그때부터 꾸준한 속도로 묶인다 (한도에 붙어 달리지 않는다)
96
+ // 즉 예전보다 순간적으로는 더 빠르고, 오래 이어질 때는 더 안전하다.
97
+ const CHANNEL_GAP_MIN_MS = 20;
79
98
  const CHANNEL_GAP_MAX_MS = 200;
80
- const CHANNEL_GAP_START_MS = 12;
81
- // 실패했을 간격을 배로 벌릴지 / 나갈 얼마씩 좁힐지
99
+ const CHANNEL_GAP_START_MS = 20;
100
+ // 규정 속도보다 이만큼 앞질러 나가는 것은 봐준다 = 몰아 보낼 있는 여유.
101
+ // 400ms 면 최소 간격 기준으로 20조각쯤을 간격 없이 연달아 부칠 수 있다. 조용했던 만큼만 쌓인다.
102
+ const CHANNEL_BURST_MS = 400;
103
+ // 실패했을 때 간격을 몇 배로 벌릴지
82
104
  const CHANNEL_GAP_GROW = 2;
83
- const CHANNEL_GAP_SHRINK_MS = 1;
105
+ // 좁히는 것은 **시간**으로 한다. 예전에는 성공 한 번에 1ms 씩 좁혔는데, 200ms 까지 벌어지면
106
+ // 되돌아오는 데 성공 188번이 필요했다 — 그 뒤로 뜸해진 세션은 한참을 느린 채로 남았다.
107
+ // 이제는 마지막 실패로부터 이만큼 지날 때마다 한 단계씩 좁힌다 (조용해도 저절로 회복한다).
108
+ const CHANNEL_RECOVER_MS = 5_000;
109
+ const CHANNEL_GAP_SHRINK = 0.7;
84
110
  // 슬롯 예약이 이만큼 넘게 밀렸으면 = 통로가 막혔다는 뜻. 이때는 프레임을 **더 부치지 않고**
85
111
  // 출력을 계속 모은다(coalescing). 터미널은 "최종 화면"만 맞으면 되므로 중간 프레임을 합쳐도
86
112
  // 잃는 게 없다 — 오히려 막힌 통로에 프레임을 밀어 넣지 않아서 훨씬 빨리 따라잡는다.
@@ -114,23 +140,14 @@ const IDLE_MS = 4_000; // 이만큼 조용하면 "끝났다" 로 본다
114
140
  // 시간만 보면 "천천히 오래 타이핑한 것" 도 일하는 중으로 잘못 본다(글자마다 화면에 되비치므로).
115
141
  // 실제 작업은 출력량이 비교가 안 되게 많아서(회전 표시·중간 결과·빌드 로그) 양으로 걸러낸다.
116
142
  const BUSY_MIN_BYTES = 2048;
117
- // 보낼 글을 프레임 크기(UTF-8 바이트)에 맞춰 조각낸다.
118
- // 글자 중간에서 자르면 글자가 깨지므로(한글은 3바이트, 이모지는 4바이트) 반드시 글자 경계에서 자른다.
119
- function chunkByBytes(text, budget) {
120
- const buf = Buffer.from(text, 'utf8');
121
- if (buf.length <= budget)
122
- return text ? [text] : [];
123
- const parts = [];
124
- let start = 0;
125
- while (start < buf.length) {
126
- let end = Math.min(buf.length, start + budget);
127
- // UTF-8 에서 이어지는 바이트는 10xxxxxx 로 시작한다 — 거기서 끊으면 글자가 반쪽이 된다
128
- while (end > start + 1 && end < buf.length && (buf[end] & 0xc0) === 0x80)
129
- end--;
130
- parts.push(buf.toString('utf8', start, end));
131
- start = end;
132
- }
133
- return parts;
143
+ // 반으로 자를 자리. 이모지처럼 칸을 차지하는 글자의 뒷짝 바로 앞에서 끊으면 글자가 깨지므로
144
+ // 그때는 옮긴다. (자바스크립트 글자열은 UTF-16 이라 0xDC00~0xDFFF '뒷짝' 이다)
145
+ function splitPoint(text) {
146
+ let mid = text.length >> 1;
147
+ const code = text.charCodeAt(mid);
148
+ if (code >= 0xdc00 && code <= 0xdfff)
149
+ mid += 1;
150
+ return Math.min(Math.max(mid, 1), text.length - 1);
134
151
  }
135
152
  // 폰의 폴더 탐색기를 열 때 "지금 셸이 있는 폴더"에서 시작하려면, 세션을 시작한 폴더가 아니라
136
153
  // 셸 프로세스의 실제 작업 폴더를 봐야 한다(사용자가 cd 로 옮겨 다니므로). 리눅스는 /proc 로 바로 알 수 있고,
@@ -243,28 +260,44 @@ export async function runAgent(options) {
243
260
  // 여기서 지킨다. 세션마다 따로 간격을 지켜도 세션이 여럿이면 채널로는 한도를 넘고, 넘는 순간부터
244
261
  // ack 이 안 돌아와 그 출력이 밀리고 화면이 멈춘다.
245
262
  //
246
- // 간격은 **고정하지 않는다**. 나가면 조금씩 좁히고(–1ms), 실패하면 배로 벌린다.
263
+ // 간격은 **고정하지 않는다**. 실패하면 배로 벌리고, 실패 없이 시간이 지나면 스스로 좁힌다.
247
264
  // 고정값은 "세션 하나에서 잰 값"이라 세션이 늘어나는 순간 그대로 무너졌다 (§상수 주석 참고).
248
265
  let channelGapMs = CHANNEL_GAP_START_MS;
266
+ let lastWidenAt = 0;
267
+ // 좁히기는 시간이 지나면 저절로 된다 — 프레임이 오가는 길목에서 확인한다.
268
+ // (예전에는 "성공 한 번에 1ms" 라, 뜸해진 세션은 영영 느린 채로 남았다)
249
269
  const easeChannelGap = () => {
250
- if (channelGapMs > CHANNEL_GAP_MIN_MS) {
251
- channelGapMs = Math.max(CHANNEL_GAP_MIN_MS, channelGapMs - CHANNEL_GAP_SHRINK_MS);
252
- }
270
+ if (channelGapMs <= CHANNEL_GAP_MIN_MS)
271
+ return;
272
+ const now = Date.now();
273
+ if (now - lastWidenAt < CHANNEL_RECOVER_MS)
274
+ return;
275
+ lastWidenAt = now;
276
+ channelGapMs = Math.max(CHANNEL_GAP_MIN_MS, Math.round(channelGapMs * CHANNEL_GAP_SHRINK));
277
+ console.error(`통로가 한동안 조용해서 전송 간격을 ${channelGapMs}ms 로 되돌렸어요`);
253
278
  };
254
279
  const widenChannelGap = (why) => {
280
+ lastWidenAt = Date.now();
255
281
  if (channelGapMs >= CHANNEL_GAP_MAX_MS)
256
282
  return;
257
283
  channelGapMs = Math.min(CHANNEL_GAP_MAX_MS, Math.max(1, channelGapMs) * CHANNEL_GAP_GROW);
258
284
  console.error(`통로가 막혀서 전송 간격을 ${channelGapMs}ms 로 벌렸어요 (${why})`);
259
285
  };
286
+ // "이 시각 이후여야 규정 속도에 맞는다" 는 가상의 시계. 부칠 때마다 간격만큼 앞으로 간다.
287
+ // 조용히 있으면 이 시계가 현재보다 뒤처지는데, **그 뒤처진 만큼이 곧 몰아 보낼 여유**다.
260
288
  let nextSlotAt = 0;
261
- // 지금 슬롯 예약이 얼마나 미래로 밀려 있는지. 이게 크면 = 통로가 막혔다는 뜻이고,
262
- // 그때 프레임을 더 부치면 상황이 나빠지기만 한다 (예전에 3초씩 밀리던 직접 원인).
263
- const slotLagMs = () => Math.max(0, nextSlotAt - Date.now());
289
+ // 지금 예약이 얼마나 미래로 밀려 있는지 (몰아 보내기 여유를 빼고 센다). 이게 크면 = 통로가
290
+ // 막혔다는 뜻이고, 그때 프레임을 더 부치면 상황이 나빠지기만 한다 (3초씩 밀리던 직접 원인).
291
+ const slotLagMs = () => Math.max(0, nextSlotAt - CHANNEL_BURST_MS - Date.now());
264
292
  const takeSendSlot = async () => {
265
293
  const now = Date.now();
266
- const at = Math.max(now, nextSlotAt);
267
- nextSlotAt = at + channelGapMs;
294
+ // 규정상 나갈 시각. 조용했으면 지금이고, 몰아 보내는 중이면 앞선 것들 뒤로 밀린다.
295
+ const due = Math.max(now, nextSlotAt);
296
+ nextSlotAt = due + channelGapMs;
297
+ // 규정보다 CHANNEL_BURST_MS 만큼 앞질러 나가는 것은 봐준다 — 조각 몇 개는 간격 없이 곧바로
298
+ // 나가고, 여유를 다 쓴 뒤에야 꾸준한 속도로 묶인다.
299
+ // (예약을 **부르는 즉시** 잡아두므로 부른 순서 = 나가는 순서는 그대로 지켜진다)
300
+ const at = Math.max(now, due - CHANNEL_BURST_MS);
268
301
  if (at > now)
269
302
  await new Promise((resolve) => setTimeout(resolve, at - now));
270
303
  };
@@ -298,17 +331,42 @@ export async function runAgent(options) {
298
331
  session.sendChain = next.then(() => undefined, () => undefined);
299
332
  return next;
300
333
  };
301
- // 실제로 쓰는 조각 크기. 서버가 "너무 크다" 고 거부하면 절반으로 줄여서 스스로 맞춘다 —
302
- // 크기 때문에 거부당하는 것은 다시 보내도 똑같이 거부되므로(= 검은 화면이 영영 안 풀린다),
303
- // 재시도 전에 반드시 크기를 줄여야 한다.
304
- let frameBudget = FRAME_BUDGET_BYTES;
305
- const shrinkFrameBudget = (why) => {
306
- if (frameBudget <= MIN_FRAME_BUDGET_BYTES)
334
+ // 실제로 쓰는 조각 크기 (통로에 실리는 크기 기준). 서버가 "너무 크다" 고 거부하면 절반으로
335
+ // 줄여서 스스로 맞춘다 — 크기 때문에 거부당하는 것은 다시 보내도 똑같이 거부되므로
336
+ // (= 검은 화면이 영영 안 풀린다), 재시도 전에 반드시 크기를 줄여야 한다.
337
+ let wireBudget = WIRE_BUDGET_BYTES;
338
+ const shrinkWireBudget = (why) => {
339
+ if (wireBudget <= MIN_WIRE_BUDGET_BYTES)
307
340
  return false;
308
- frameBudget = Math.max(MIN_FRAME_BUDGET_BYTES, Math.floor(frameBudget / 2));
309
- console.error(`조각 크기를 ${Math.round(frameBudget / 1024)}KB 로 줄였어요 (${why})`);
341
+ wireBudget = Math.max(MIN_WIRE_BUDGET_BYTES, Math.floor(wireBudget / 2));
342
+ console.error(`조각 크기를 ${Math.round(wireBudget / 1024)}KB 로 줄였어요 (${why})`);
310
343
  return true;
311
344
  };
345
+ // 보낼 글을 프레임 조각들로 만든다 — **압축·암호화까지 실제로 해보고 크기를 재서** 나눈다.
346
+ //
347
+ // 압축을 넣으면 "원본 몇 바이트로 자를까" 는 답이 없는 질문이 된다. 같은 40KB라도 색이 촘촘한
348
+ // 화면과 회전 표시만 도는 화면은 압축 후 크기가 열 배 넘게 차이 난다. 짐작해서 자르면 크게
349
+ // 잡을 땐 한도를 넘어 통째로 거부되고(폰은 새까만 화면), 작게 잡을 땐 한 조각이면 될 것을
350
+ // 다섯 조각으로 쪼갠다(조각 사이 틈 = 검은 화면, 유실 확률도 조각 수만큼).
351
+ //
352
+ // 그래서 짐작하지 않는다. 통째로 담아보고, 안 들어가면 반으로 잘라 다시 담아본다.
353
+ // 잘 압축되는 화면은 그대로 **한 조각**으로 끝난다 (실측: 25KB 출력 → 7KB, 약 4.8배).
354
+ const packOutput = (session, text) => {
355
+ const pack = (part, depth) => {
356
+ // 타자 한 글자가 되비치는 것까지 압축하면 머리말 때문에 오히려 커진다 — 큰 것만 압축한다
357
+ const useGz = session.gzipOk && Buffer.byteLength(part, 'utf8') >= GZIP_MIN_BYTES;
358
+ const data = useGz
359
+ ? encryptBytes(compress(part), session.frameKey)
360
+ : encryptFrame(part, session.frameKey);
361
+ // 한 글자까지 줄여도 안 들어가면 더 쪼갤 수가 없다 — 그냥 보내고 결과에 맡긴다
362
+ if (data.length <= wireBudget || part.length <= 1 || depth >= MAX_PACK_DEPTH) {
363
+ return [useGz ? { data, gz: true } : { data }];
364
+ }
365
+ const cut = splitPoint(part);
366
+ return [...pack(part.slice(0, cut), depth + 1), ...pack(part.slice(cut), depth + 1)];
367
+ };
368
+ return text ? pack(text, 0) : [];
369
+ };
312
370
  // 지금까지 나온 출력이 '보이지 않는 터미널' 에 다 반영되기를 기다린다.
313
371
  // (xterm 은 받은 글자를 곧바로가 아니라 조금 뒤에 처리한다 — 실측 확인)
314
372
  const drainScreen = (session) => new Promise((resolve) => {
@@ -341,7 +399,8 @@ export async function runAgent(options) {
341
399
  const results = await Promise.allSettled(parts.map((part, index) => sendFrame({
342
400
  kind: 'output',
343
401
  to: session.deviceId,
344
- data: encryptFrame(part, session.frameKey),
402
+ data: part.data,
403
+ ...(part.gz ? { gz: true } : {}),
345
404
  // 복원 조각임을 표시해서, 폰이 다 모은 뒤 한 번에 그리게 한다 (검은 화면 방지)
346
405
  seq: index,
347
406
  total: parts.length,
@@ -355,7 +414,7 @@ export async function runAgent(options) {
355
414
  const message = String(reason?.message ?? reason);
356
415
  console.error(`출력 전송 실패 (${sent}/${parts.length} 조각만 나감):`, message);
357
416
  if (/too large|TOO_LARGE|size|크기/i.test(message))
358
- shrinkFrameBudget(message);
417
+ shrinkWireBudget(message);
359
418
  session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
360
419
  }
361
420
  return sent;
@@ -384,15 +443,17 @@ export async function runAgent(options) {
384
443
  // 없다가 한꺼번에 쏟아지는 것처럼 보였다 — "타자가 2초 뒤에 몰아서 보인다" 의 정체.
385
444
  await enqueueSend(session, async () => {
386
445
  try {
387
- // 프레임 한도를 넘지 않도록 나눠 보낸다 (한 덩이로 보내면 통째로 거부돼 화면이 멈춘다)
388
- const parts = chunkByBytes(pending, frameBudget);
446
+ // 프레임 한도를 넘지 않도록 나눠 보낸다 (한 덩이로 보내면 통째로 거부돼 화면이 멈춘다).
447
+ // 압축이 되는 폰이면 대개 한 조각으로 끝난다 — 조각이 적을수록 빠르고 덜 유실된다.
448
+ const parts = packOutput(session, pending);
389
449
  session.inFlight += parts.length;
390
450
  // send() 를 부르는 순간 이미 통로로 나간다. 부른 순서 = 나가는 순서라서(실측 확인)
391
451
  // 답을 기다리지 않아도 조각이 뒤섞이지 않는다.
392
452
  const results = parts.map((part) => sendFrame({
393
453
  kind: 'output',
394
454
  to: session.deviceId,
395
- data: encryptFrame(part, session.frameKey),
455
+ data: part.data,
456
+ ...(part.gz ? { gz: true } : {}),
396
457
  }));
397
458
  void Promise.allSettled(results)
398
459
  .then((settled) => {
@@ -405,7 +466,7 @@ export async function runAgent(options) {
405
466
  const message = String(reason?.message ?? reason);
406
467
  console.error(`출력 전송 실패 (${sent}/${parts.length} 조각만 나감):`, message);
407
468
  if (/too large|TOO_LARGE|size|크기/i.test(message))
408
- shrinkFrameBudget(message);
469
+ shrinkWireBudget(message);
409
470
  session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
410
471
  // 못 간 것을 다시 보내는 대신 **화면을 통째로 다시 맞춘다**.
411
472
  // 답을 기다리지 않고 잇달아 부치기 때문에, 실패를 알게 된 시점에는 그 뒤의 출력이
@@ -498,7 +559,7 @@ export async function runAgent(options) {
498
559
  }
499
560
  // 화면을 먼저 지우고(2J·3J) 복원 내용을 보내야, 폰에 남아있던 예전 화면과 섞이지 않는다
500
561
  const payload = `\x1b[H\x1b[2J\x1b[3J${snapshot}`;
501
- const parts = chunkByBytes(payload, frameBudget);
562
+ const parts = packOutput(session, payload);
502
563
  const sent = await enqueueSend(session, () => sendParts(session, parts));
503
564
  if (sent < parts.length) {
504
565
  // 화면을 지우라는 첫 조각만 도착하고 내용이 끊긴 상태 = 폰에는 **새까만 화면**만 남는다.
@@ -536,7 +597,7 @@ export async function runAgent(options) {
536
597
  // 두 번 연달아 실패하면 이유가 뭐라고 적혀 있든 크기부터 의심한다 — 같은 크기로 계속
537
598
  // 다시 보내봐야 같은 이유로 막히고, 그동안 폰은 계속 새까만 화면이다.
538
599
  if (session.restoreFails >= 2)
539
- shrinkFrameBudget('화면 복원이 거듭 실패');
600
+ shrinkWireBudget('화면 복원이 거듭 실패');
540
601
  console.error(`화면 복원이 중간에 끊겼어요 ("${session.name}") — ${wait}ms 뒤 다시 보낼게요.`);
541
602
  session.restoreRetry = setTimeout(() => {
542
603
  session.restoreRetry = null;
@@ -714,6 +775,8 @@ export async function runAgent(options) {
714
775
  hookSeen: false,
715
776
  temporary,
716
777
  viewerSeenAt: Date.now(),
778
+ // 폰이 접속하면서 "압축 읽을 수 있다" 고 알려주기 전까지는 압축하지 않는다
779
+ gzipOk: false,
717
780
  };
718
781
  session.screen.loadAddon(session.serializer);
719
782
  session.pty.onData((data) => {
@@ -880,6 +943,13 @@ export async function runAgent(options) {
880
943
  // 폰에서 온 프레임 = 누군가 이 세션을 보고 있다는 뜻 (임시 터미널 정리·출력 게이트에 쓴다)
881
944
  const wasUnwatched = !hasViewer(session);
882
945
  session.viewerSeenAt = Date.now();
946
+ // 압축된 화면을 읽을 수 있는 폰인지. 접속할 때(connect_request)는 **그 폰이 말한 그대로** 맞춘다 —
947
+ // 옛 폰이 새로 붙으면 압축을 꺼야 하기 때문이다(못 읽는 폰에 보내면 화면이 통째로 깨진다).
948
+ // 그 밖의 프레임에서는 켜기만 한다(핑·크기변경에는 이 표시가 없을 수 있으므로 끄면 안 된다).
949
+ if (frame.kind === 'connect_request')
950
+ session.gzipOk = frame.gzOk === true;
951
+ else if (frame.gzOk)
952
+ session.gzipOk = true;
883
953
  // 안 보는 동안에는 실시간 프레임을 만들지 않으므로(pty.onData 참고), 다시 보기 시작하면
884
954
  // 그 사이 화면이 어떻게 바뀌었는지 알 길이 없다 — 지금 화면을 통째로 보내 맞춰준다.
885
955
  // connect_request 는 아래에서 어차피 복원하므로 여기서 두 번 하지 않는다.
package/dist/crypto.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import crypto from 'node:crypto';
2
+ import zlib from 'node:zlib';
2
3
  const ALGO = 'aes-256-gcm';
3
4
  const IV_LEN = 12;
4
5
  const TAG_LEN = 16;
@@ -9,15 +10,15 @@ const TAG_LEN = 16;
9
10
  export function generateFrameKey() {
10
11
  return crypto.randomBytes(32).toString('base64');
11
12
  }
12
- export function encryptFrame(plaintext, keyB64) {
13
+ export function encryptBytes(plain, keyB64) {
13
14
  const key = Buffer.from(keyB64, 'base64');
14
15
  const iv = crypto.randomBytes(IV_LEN);
15
16
  const cipher = crypto.createCipheriv(ALGO, key, iv);
16
- const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
17
+ const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]);
17
18
  const authTag = cipher.getAuthTag();
18
19
  return Buffer.concat([iv, ciphertext, authTag]).toString('base64');
19
20
  }
20
- export function decryptFrame(payloadB64, keyB64) {
21
+ export function decryptToBytes(payloadB64, keyB64) {
21
22
  const key = Buffer.from(keyB64, 'base64');
22
23
  const raw = Buffer.from(payloadB64, 'base64');
23
24
  const iv = raw.subarray(0, IV_LEN);
@@ -25,5 +26,24 @@ export function decryptFrame(payloadB64, keyB64) {
25
26
  const ciphertext = raw.subarray(IV_LEN, raw.length - TAG_LEN);
26
27
  const decipher = crypto.createDecipheriv(ALGO, key, iv);
27
28
  decipher.setAuthTag(authTag);
28
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
29
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
30
+ }
31
+ export function encryptFrame(plaintext, keyB64) {
32
+ return encryptBytes(Buffer.from(plaintext, 'utf8'), keyB64);
33
+ }
34
+ export function decryptFrame(payloadB64, keyB64) {
35
+ return decryptToBytes(payloadB64, keyB64).toString('utf8');
36
+ }
37
+ // ── 압축 ────────────────────────────────────────────────────────────────────
38
+ // 터미널 출력은 같은 것이 끝없이 반복된다(색 코드·회전 표시·화면 전체 다시 그리기). 실측으로
39
+ // 5배 안팎 줄어드는데, 이 통로의 병목은 굵기가 아니라 **초당 보낼 수 있는 건수**라서 크기가
40
+ // 줄면 조각 수가 그대로 줄어든다 — 조각 사이의 틈이 곧 검은 화면이었으므로 효과가 크다.
41
+ //
42
+ // 반드시 **압축 → 암호화** 순서다. 암호문은 무작위에 가까워서 뒤에 압축하면 오히려 커진다.
43
+ // 압축 비용은 실측 0.68ms/256KB (level 6) 로 사실상 없다.
44
+ export function compress(plaintext) {
45
+ return zlib.gzipSync(Buffer.from(plaintext, 'utf8'));
46
+ }
47
+ export function decompress(raw) {
48
+ return zlib.gunzipSync(raw).toString('utf8');
29
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellbase",
3
- "version": "0.13.5",
3
+ "version": "0.13.6",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {