shellbase 0.13.2 → 0.13.4

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/dist/agent.js +54 -14
  2. package/package.json +1 -1
package/dist/agent.js CHANGED
@@ -57,6 +57,12 @@ const MAX_PENDING_OUTPUT = 256 * 1024;
57
57
  // 서버가 40회 연속 전송도 거부 없이 받아줬고 이 값이 타이핑 체감 지연의 주범이었음(입력·출력 양쪽에서
58
58
  // 각각 최대 120ms). 30ms 로 낮춰 체감을 줄이고, 출력이 몰릴 때는 여전히 초당 33회로 묶인다.
59
59
  const MIN_SEND_INTERVAL_MS = 30;
60
+ // 서버 답(ack)을 안 기다리고 잇달아 부칠 수 있는 조각 수. 답을 하나씩 기다리면 조각마다 왕복
61
+ // 한 번(실측 276ms)이 통째로 지연이 되어, 출력이 조금만 나뉘어도 몇 초 뒤에 한꺼번에 나타났다
62
+ // (10조각 = 2.7초. 같은 것을 잇달아 부치면 0.29초 — 순서도 그대로 지켜지는 것을 실측 확인).
63
+ // 그래도 무한정 부치면 통로가 막혔을 때 밀린 조각이 계속 쌓이므로, 이만큼만 부쳐두고
64
+ // 답이 하나 돌아올 때마다 이어서 보낸다 (막히면 저절로 모아 보내는 효과도 있다).
65
+ const MAX_FRAMES_IN_FLIGHT = 6;
60
66
  // 그래도 서버가 거부하면(RATE_LIMITED 등) 잠시 간격을 넉넉히 벌려서 재시도한다
61
67
  const SEND_PENALTY_MS = 500;
62
68
  const PENALTY_INTERVAL_MS = 250;
@@ -288,10 +294,13 @@ export async function runAgent(options) {
288
294
  // (세션을 전환할 때마다 검은 화면이 몇 초씩 남다가 뒤늦게 돌아오던 원인).
289
295
  // 통로는 부친 순서대로 내보내므로, 한꺼번에 부치고 결과만 모아서 확인한다.
290
296
  const sendParts = async (session, parts) => {
291
- const results = await Promise.allSettled(parts.map((part) => sendFrame({
297
+ const results = await Promise.allSettled(parts.map((part, index) => sendFrame({
292
298
  kind: 'output',
293
299
  to: session.deviceId,
294
300
  data: encryptFrame(part, session.frameKey),
301
+ // 복원 조각임을 표시해서, 폰이 다 모은 뒤 한 번에 그리게 한다 (검은 화면 방지)
302
+ seq: index,
303
+ total: parts.length,
295
304
  })));
296
305
  let sent = 0;
297
306
  while (sent < results.length && results[sent].status === 'fulfilled')
@@ -325,23 +334,49 @@ export async function runAgent(options) {
325
334
  }
326
335
  session.flushing = true;
327
336
  session.lastSentAt = Date.now();
337
+ // 부치는 *순서*만 이 줄에서 지키고, **서버 답(ack)은 기다리지 않는다**.
338
+ // 예전에는 조각 하나를 부치고 답이 올 때까지(왕복 276ms) 다음 조각을 붙들고 있었다.
339
+ // 그래서 출력이 열 조각으로 나뉘면 2.7초 뒤에야 마지막이 도착했고, 화면에는 한참 아무것도
340
+ // 없다가 한꺼번에 쏟아지는 것처럼 보였다 — "타자가 2초 뒤에 몰아서 보인다" 의 정체.
328
341
  await enqueueSend(session, async () => {
329
342
  try {
330
343
  // 프레임 한도를 넘지 않도록 나눠 보낸다 (한 덩이로 보내면 통째로 거부돼 화면이 멈춘다)
331
344
  const parts = chunkByBytes(pending, frameBudget);
332
- const sent = await sendParts(session, parts);
333
- if (sent < parts.length) {
334
- if (parts.length === 1) {
335
- // 조각이 하나뿐인데 못 갔다 = 아무것도 갔다. 그대로 다음 전송 앞에 다시 붙인다.
336
- session.outBuffer = pending + session.outBuffer;
337
- }
338
- else if (!session.closing) {
339
- // 여러 조각을 한꺼번에 부쳤는데 일부만 갔다. 못 간 것만 다시 보내면 이미 도착한 뒷조각이
340
- // 두 번 그려질 수 있다 — 대신 화면을 통째로 다시 보낸다. 지금 화면이 어떻게 생겼는지는
341
- // 이쪽(session.screen) 정확히 들고 있어서, 빠지지도 겹치지도 않는다.
345
+ session.inFlight += parts.length;
346
+ // send() 부르는 순간 이미 통로로 나간다. 부른 순서 = 나가는 순서라서(실측 확인)
347
+ // 답을 기다리지 않아도 조각이 뒤섞이지 않는다.
348
+ const results = parts.map((part) => sendFrame({
349
+ kind: 'output',
350
+ to: session.deviceId,
351
+ data: encryptFrame(part, session.frameKey),
352
+ }));
353
+ void Promise.allSettled(results)
354
+ .then((settled) => {
355
+ let sent = 0;
356
+ while (sent < settled.length && settled[sent].status === 'fulfilled')
357
+ sent += 1;
358
+ if (sent >= settled.length)
359
+ return;
360
+ const reason = settled[sent].reason;
361
+ const message = String(reason?.message ?? reason);
362
+ console.error(`출력 전송 실패 (${sent}/${parts.length} 조각만 나감):`, message);
363
+ if (/too large|TOO_LARGE|size|크기/i.test(message))
364
+ shrinkFrameBudget(message);
365
+ session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
366
+ // 못 간 것을 다시 보내는 대신 **화면을 통째로 다시 보낸다**.
367
+ // 답을 기다리지 않고 잇달아 부치기 때문에, 실패를 알게 된 시점에는 그 뒤의 출력이
368
+ // 이미 나가 있을 수 있다 — 거기에 옛 조각을 뒤늦게 끼워 넣으면 화면이 뒤섞인다.
369
+ // 지금 화면이 어떻게 생겼는지는 session.screen 이 정확히 들고 있어서,
370
+ // 통째로 다시 보내면 빠지지도 겹치지도 않는다.
371
+ if (!session.closing)
342
372
  void sendRestore(session);
343
- }
344
- }
373
+ })
374
+ .finally(() => {
375
+ session.inFlight -= parts.length;
376
+ // 답을 기다리느라 모아둔 것이 있으면 이어서 보낸다
377
+ if (session.outBuffer && !session.closing && !session.restoring)
378
+ scheduleFlush(session);
379
+ });
345
380
  }
346
381
  catch (err) {
347
382
  // 여기서 새어 나간 오류는 아무도 받아주지 않아 프로세스가 통째로 죽는다 — 반드시 잡는다
@@ -351,7 +386,7 @@ export async function runAgent(options) {
351
386
  // 무슨 일이 있어도 표시는 반드시 푼다 — 여기 걸린 채로 남으면 그 세션은 그 뒤로
352
387
  // 아무 화면도 못 보낸다 (검은 화면의 원인 중 하나)
353
388
  session.flushing = false;
354
- // 남은 것(재시도분·보내는 사이에 새로 나온 것)이 있으면 이어서 보낸다
389
+ // 보내는 사이에 새로 나온 것이 있으면 곧바로 이어서 보낸다
355
390
  if (session.outBuffer && !session.closing && !session.restoring)
356
391
  scheduleFlush(session);
357
392
  }
@@ -458,6 +493,10 @@ export async function runAgent(options) {
458
493
  const scheduleFlush = (session) => {
459
494
  if (session.pendingFlush !== null || session.flushing || session.restoring)
460
495
  return;
496
+ // 답을 못 받은 조각이 너무 많이 쌓였으면 잠시 모아둔다 — 하나가 돌아오면 그때 이어서 보낸다
497
+ // (그 사이 나온 출력은 한 덩이로 합쳐져서, 막힌 상황에서도 조각 수가 늘지 않는다)
498
+ if (session.inFlight >= MAX_FRAMES_IN_FLIGHT)
499
+ return;
461
500
  const interval = Date.now() < session.sendPenaltyUntil ? PENALTY_INTERVAL_MS : MIN_SEND_INTERVAL_MS;
462
501
  const wait = Math.max(0, interval - (Date.now() - session.lastSentAt));
463
502
  session.pendingFlush = setTimeout(() => void flush(session), wait);
@@ -578,6 +617,7 @@ export async function runAgent(options) {
578
617
  approvalInFlight: false,
579
618
  outBuffer: '',
580
619
  flushing: false,
620
+ inFlight: 0,
581
621
  sendChain: Promise.resolve(),
582
622
  restoring: false,
583
623
  restoreAgain: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellbase",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {