shellbase 0.13.4 → 0.13.5
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 +120 -19
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -66,11 +66,28 @@ const MAX_FRAMES_IN_FLIGHT = 6;
|
|
|
66
66
|
// 그래도 서버가 거부하면(RATE_LIMITED 등) 잠시 간격을 넉넉히 벌려서 재시도한다
|
|
67
67
|
const SEND_PENALTY_MS = 500;
|
|
68
68
|
const PENALTY_INTERVAL_MS = 250;
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
69
|
+
// ── 채널 페이싱 ─────────────────────────────────────────────────────────────
|
|
70
|
+
// 이 컴퓨터의 모든 세션이 실시간 통로(WebSocket) 하나를 함께 쓴다. 예전에는 여기를 12ms(초당 83건)
|
|
71
|
+
// 고정으로 두고 "실측해보니 서버가 40회 연속도 받아줬다"고 적어뒀는데, **그 실측은 세션이 하나일
|
|
72
|
+
// 때** 한 것이었다. 세션이 셋이 되는 순간 채널이 포화돼 ack 이 안 돌아왔고(`Request timeout`),
|
|
73
|
+
// 실패할 때마다 화면을 통째로 다시 보내는 바람에 프레임이 더 늘어나는 악순환이 생겼다
|
|
74
|
+
// ("타자가 사라졌다 3초 뒤에 몰아서 나타난다" 의 정체 — 세션 3개째를 연 지 50초 만에 시작됐다).
|
|
75
|
+
//
|
|
76
|
+
// 그래서 고정값을 버리고 **실제 왕복 시간을 재서 스스로 조절**한다. 잘 나가면 조금씩 좁히고,
|
|
77
|
+
// 실패하면 크게 벌린다(AIMD). 세션이 하나면 예전만큼 빠르고, 여러 개여도 스스로 안전한 값을 찾는다.
|
|
78
|
+
const CHANNEL_GAP_MIN_MS = 12;
|
|
79
|
+
const CHANNEL_GAP_MAX_MS = 200;
|
|
80
|
+
const CHANNEL_GAP_START_MS = 12;
|
|
81
|
+
// 실패했을 때 간격을 몇 배로 벌릴지 / 잘 나갈 때 얼마씩 좁힐지
|
|
82
|
+
const CHANNEL_GAP_GROW = 2;
|
|
83
|
+
const CHANNEL_GAP_SHRINK_MS = 1;
|
|
84
|
+
// 슬롯 예약이 이만큼 넘게 밀렸으면 = 통로가 막혔다는 뜻. 이때는 프레임을 **더 부치지 않고**
|
|
85
|
+
// 출력을 계속 모은다(coalescing). 터미널은 "최종 화면"만 맞으면 되므로 중간 프레임을 합쳐도
|
|
86
|
+
// 잃는 게 없다 — 오히려 막힌 통로에 프레임을 밀어 넣지 않아서 훨씬 빨리 따라잡는다.
|
|
87
|
+
const MAX_SLOT_LAG_MS = 250;
|
|
88
|
+
// 폰이 이 세션을 보고 있다고 볼 수 있는 시간. 폰은 보고 있는 동안 10초마다 ping 을 보내므로
|
|
89
|
+
// 세 번 놓칠 때까지 기다린다 (화면 잠금·터널 전환 등으로 잠깐 끊기는 것과 구분).
|
|
90
|
+
const VIEWER_ACTIVE_MS = 30_000;
|
|
74
91
|
// 화면 복원 전에 "지금까지 나온 출력이 보이지 않는 터미널에 다 반영됐는지" 기다리는 횟수.
|
|
75
92
|
// 기다리는 동안 새 출력이 또 들어오면 한 번 더 기다린다 — 쉬지 않고 쏟아지는 세션에서 영영
|
|
76
93
|
// 못 기다리는 일이 없도록 상한을 둔다.
|
|
@@ -223,13 +240,31 @@ export async function runAgent(options) {
|
|
|
223
240
|
.map((session) => ({ dir: session.cwd, name: session.name })));
|
|
224
241
|
}
|
|
225
242
|
// 이 컴퓨터의 모든 세션이 실시간 통로(WebSocket) 하나를 함께 쓴다 — 채널 전체의 전송 간격은
|
|
226
|
-
// 여기서 지킨다. 세션마다 따로 간격을 지켜도 세션이 여럿이면 채널로는
|
|
227
|
-
//
|
|
243
|
+
// 여기서 지킨다. 세션마다 따로 간격을 지켜도 세션이 여럿이면 채널로는 한도를 넘고, 넘는 순간부터
|
|
244
|
+
// ack 이 안 돌아와 그 출력이 밀리고 화면이 멈춘다.
|
|
245
|
+
//
|
|
246
|
+
// 간격은 **고정하지 않는다**. 잘 나가면 조금씩 좁히고(–1ms), 실패하면 두 배로 벌린다.
|
|
247
|
+
// 고정값은 "세션 하나에서 잰 값"이라 세션이 늘어나는 순간 그대로 무너졌다 (§상수 주석 참고).
|
|
248
|
+
let channelGapMs = CHANNEL_GAP_START_MS;
|
|
249
|
+
const easeChannelGap = () => {
|
|
250
|
+
if (channelGapMs > CHANNEL_GAP_MIN_MS) {
|
|
251
|
+
channelGapMs = Math.max(CHANNEL_GAP_MIN_MS, channelGapMs - CHANNEL_GAP_SHRINK_MS);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
const widenChannelGap = (why) => {
|
|
255
|
+
if (channelGapMs >= CHANNEL_GAP_MAX_MS)
|
|
256
|
+
return;
|
|
257
|
+
channelGapMs = Math.min(CHANNEL_GAP_MAX_MS, Math.max(1, channelGapMs) * CHANNEL_GAP_GROW);
|
|
258
|
+
console.error(`통로가 막혀서 전송 간격을 ${channelGapMs}ms 로 벌렸어요 (${why})`);
|
|
259
|
+
};
|
|
228
260
|
let nextSlotAt = 0;
|
|
261
|
+
// 지금 슬롯 예약이 얼마나 미래로 밀려 있는지. 이게 크면 = 통로가 막혔다는 뜻이고,
|
|
262
|
+
// 그때 프레임을 더 부치면 상황이 나빠지기만 한다 (예전에 3초씩 밀리던 직접 원인).
|
|
263
|
+
const slotLagMs = () => Math.max(0, nextSlotAt - Date.now());
|
|
229
264
|
const takeSendSlot = async () => {
|
|
230
265
|
const now = Date.now();
|
|
231
266
|
const at = Math.max(now, nextSlotAt);
|
|
232
|
-
nextSlotAt = at +
|
|
267
|
+
nextSlotAt = at + channelGapMs;
|
|
233
268
|
if (at > now)
|
|
234
269
|
await new Promise((resolve) => setTimeout(resolve, at - now));
|
|
235
270
|
};
|
|
@@ -237,7 +272,16 @@ export async function runAgent(options) {
|
|
|
237
272
|
// (그래서 아래에서 여러 조각을 한꺼번에 부쳐도 순서가 지켜진다).
|
|
238
273
|
const sendFrame = async (frame) => {
|
|
239
274
|
await takeSendSlot();
|
|
240
|
-
|
|
275
|
+
try {
|
|
276
|
+
await channel.send(frame, { includeSelf: false });
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
// 실패 = 통로가 감당 못 하고 있다는 가장 확실한 신호. 여기서 간격을 벌려두면
|
|
280
|
+
// 뒤따르는 프레임들이 자동으로 느슨해져서 스스로 회복한다.
|
|
281
|
+
widenChannelGap(err.message);
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
284
|
+
easeChannelGap();
|
|
241
285
|
};
|
|
242
286
|
const send = async (frame) => {
|
|
243
287
|
try {
|
|
@@ -363,13 +407,13 @@ export async function runAgent(options) {
|
|
|
363
407
|
if (/too large|TOO_LARGE|size|크기/i.test(message))
|
|
364
408
|
shrinkFrameBudget(message);
|
|
365
409
|
session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
|
|
366
|
-
// 못 간 것을 다시 보내는 대신 **화면을 통째로 다시
|
|
410
|
+
// 못 간 것을 다시 보내는 대신 **화면을 통째로 다시 맞춘다**.
|
|
367
411
|
// 답을 기다리지 않고 잇달아 부치기 때문에, 실패를 알게 된 시점에는 그 뒤의 출력이
|
|
368
412
|
// 이미 나가 있을 수 있다 — 거기에 옛 조각을 뒤늦게 끼워 넣으면 화면이 뒤섞인다.
|
|
369
413
|
// 지금 화면이 어떻게 생겼는지는 session.screen 이 정확히 들고 있어서,
|
|
370
414
|
// 통째로 다시 보내면 빠지지도 겹치지도 않는다.
|
|
371
|
-
|
|
372
|
-
|
|
415
|
+
// 다만 **그 자리에서 바로 보내지는 않는다** — scheduleResync 주석 참고.
|
|
416
|
+
scheduleResync(session, message);
|
|
373
417
|
})
|
|
374
418
|
.finally(() => {
|
|
375
419
|
session.inFlight -= parts.length;
|
|
@@ -394,6 +438,16 @@ export async function runAgent(options) {
|
|
|
394
438
|
};
|
|
395
439
|
// 폰이 "화면을 통째로 다시 달라"고 할 때 (처음 접속·재연결·크기 변경 뒤).
|
|
396
440
|
const sendRestore = async (session) => {
|
|
441
|
+
// 아무도 안 보는 세션에 화면을 보내봐야 받는 사람이 없다 — 통로만 먹는다.
|
|
442
|
+
// (다시 보기 시작하면 그때 ping/connect_request 가 오고, 거기서 복원한다)
|
|
443
|
+
if (!hasViewer(session)) {
|
|
444
|
+
if (session.resyncTimer !== null) {
|
|
445
|
+
clearTimeout(session.resyncTimer);
|
|
446
|
+
session.resyncTimer = null;
|
|
447
|
+
}
|
|
448
|
+
session.outBuffer = '';
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
397
451
|
if (session.restoring) {
|
|
398
452
|
// 보내는 중에 또 요청이 왔다 — 지금 것이 끝난 뒤 한 번만 더 보낸다.
|
|
399
453
|
// 두 벌을 동시에 보내면 조각이 서로 끼어들어 폰 화면에 같은 내용이 두 번 그려진다.
|
|
@@ -490,6 +544,26 @@ export async function runAgent(options) {
|
|
|
490
544
|
void sendRestore(session);
|
|
491
545
|
}, wait);
|
|
492
546
|
};
|
|
547
|
+
// 폰이 지금 이 세션을 보고 있는가. 폰은 보고 있는 세션에만 10초마다 ping 을 보내므로,
|
|
548
|
+
// 마지막 신호가 최근이면 "보고 있다" 로 본다. 이 판단 하나가 채널 부하를 세션 수만큼 줄인다.
|
|
549
|
+
const hasViewer = (session) => Date.now() - session.viewerSeenAt < VIEWER_ACTIVE_MS;
|
|
550
|
+
// 화면을 통째로 다시 맞춰야 할 때 쓴다 — 단, **곧바로 보내지 않는다**.
|
|
551
|
+
// 예전에는 프레임 하나가 실패할 때마다 그 자리에서 화면 전체를 다시 보냈다. 통로가 막혀서
|
|
552
|
+
// 실패한 것인데 가장 큰 프레임을 더 밀어 넣는 셈이라, 실패가 실패를 부르며 눈덩이처럼 커졌다
|
|
553
|
+
// (실패 106회 · 복원까지 연쇄 실패). 이제는 잠깐 모았다가 **한 번만** 보낸다 — 그 사이 실패가
|
|
554
|
+
// 열 번 더 나도 복원은 여전히 한 번이고, 통로에는 숨 쉴 틈이 생긴다.
|
|
555
|
+
const scheduleResync = (session, why) => {
|
|
556
|
+
if (session.closing || session.resyncTimer !== null)
|
|
557
|
+
return;
|
|
558
|
+
// 통로가 밀려 있으면 그만큼 더 기다렸다 보낸다 (막힌 곳에 큰 프레임을 얹지 않는다)
|
|
559
|
+
const wait = Math.max(SEND_PENALTY_MS, slotLagMs());
|
|
560
|
+
console.error(`화면을 ${wait}ms 뒤에 한 번 다시 맞출게요 ("${session.name}" — ${why})`);
|
|
561
|
+
session.resyncTimer = setTimeout(() => {
|
|
562
|
+
session.resyncTimer = null;
|
|
563
|
+
if (!session.closing)
|
|
564
|
+
void sendRestore(session);
|
|
565
|
+
}, wait);
|
|
566
|
+
};
|
|
493
567
|
const scheduleFlush = (session) => {
|
|
494
568
|
if (session.pendingFlush !== null || session.flushing || session.restoring)
|
|
495
569
|
return;
|
|
@@ -498,7 +572,11 @@ export async function runAgent(options) {
|
|
|
498
572
|
if (session.inFlight >= MAX_FRAMES_IN_FLIGHT)
|
|
499
573
|
return;
|
|
500
574
|
const interval = Date.now() < session.sendPenaltyUntil ? PENALTY_INTERVAL_MS : MIN_SEND_INTERVAL_MS;
|
|
501
|
-
|
|
575
|
+
// 통로가 막혀 슬롯 예약이 한참 밀려 있으면, 지금 부쳐봐야 그 프레임은 몇 초 뒤에나 나간다.
|
|
576
|
+
// 그럴 바엔 **부치지 말고 계속 모은다** — 밀린 만큼 기다렸다 한 덩이로 보내면 조각 수가
|
|
577
|
+
// 늘지 않아 통로가 스스로 풀리고, 폰에는 오히려 더 빨리 최신 화면이 도착한다.
|
|
578
|
+
const lag = slotLagMs();
|
|
579
|
+
const wait = lag > MAX_SLOT_LAG_MS ? lag : Math.max(0, interval - (Date.now() - session.lastSentAt));
|
|
502
580
|
session.pendingFlush = setTimeout(() => void flush(session), wait);
|
|
503
581
|
};
|
|
504
582
|
// ── 작업 완료 알림 ──────────────────────────────────────────────────────────
|
|
@@ -623,6 +701,7 @@ export async function runAgent(options) {
|
|
|
623
701
|
restoreAgain: false,
|
|
624
702
|
restoreRetry: null,
|
|
625
703
|
restoreFails: 0,
|
|
704
|
+
resyncTimer: null,
|
|
626
705
|
lastSentAt: 0,
|
|
627
706
|
sendPenaltyUntil: 0,
|
|
628
707
|
pendingFlush: null,
|
|
@@ -638,11 +717,21 @@ export async function runAgent(options) {
|
|
|
638
717
|
};
|
|
639
718
|
session.screen.loadAddon(session.serializer);
|
|
640
719
|
session.pty.onData((data) => {
|
|
641
|
-
|
|
642
|
-
//
|
|
720
|
+
// 화면 상태는 보든 안 보든 항상 따라 그려둔다 — 여기가 "지금 화면이 어떻게 생겼는지" 의
|
|
721
|
+
// 유일한 진실이고, 폰이 (다시) 들어오면 여기서 통째로 복원해 보낸다.
|
|
643
722
|
session.screen.write(data);
|
|
644
|
-
scheduleFlush(session);
|
|
645
723
|
trackBusy(session, data.length);
|
|
724
|
+
// 🔴 **아무도 안 보고 있으면 실시간 프레임을 아예 만들지 않는다.**
|
|
725
|
+
// 폰은 한 번에 세션 하나만 화면에 띄우는데, 예전에는 열려 있는 모든 세션이 각자 출력을
|
|
726
|
+
// 채널로 밀어 넣고 있었다. 세션이 넷이면 채널 부하가 그대로 4배가 되고, 정작 폰은 그중
|
|
727
|
+
// 셋을 받아서 버렸다 — 보이지도 않는 화면 때문에 보이는 화면이 3초씩 밀렸던 것이다.
|
|
728
|
+
// 안 보는 동안 쌓인 것은 위 screen 에 이미 다 들어 있으므로 하나도 잃지 않는다.
|
|
729
|
+
if (!hasViewer(session)) {
|
|
730
|
+
session.outBuffer = '';
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
session.outBuffer += data;
|
|
734
|
+
scheduleFlush(session);
|
|
646
735
|
});
|
|
647
736
|
session.pty.onExit(() => {
|
|
648
737
|
console.log(`"${session.name}" 세션의 셸이 종료돼서 세션도 닫아요.`);
|
|
@@ -679,6 +768,8 @@ export async function runAgent(options) {
|
|
|
679
768
|
clearTimeout(session.pendingFlush);
|
|
680
769
|
if (session.restoreRetry !== null)
|
|
681
770
|
clearTimeout(session.restoreRetry);
|
|
771
|
+
if (session.resyncTimer !== null)
|
|
772
|
+
clearTimeout(session.resyncTimer);
|
|
682
773
|
if (session.idleTimer !== null)
|
|
683
774
|
clearTimeout(session.idleTimer);
|
|
684
775
|
await flush(session);
|
|
@@ -757,8 +848,12 @@ export async function runAgent(options) {
|
|
|
757
848
|
// 접두사를 붙여 UUID 로 보이지 않게 만든다. 프레임 배분은 userId 가 아니라 frame.to 로 한다.
|
|
758
849
|
// timeout 은 "보낸 것에 서버가 답하기를 기다리는 시간" 이기도 하다. 기본값 30초는 터미널에는
|
|
759
850
|
// 너무 길다 — 한 번 막히면 그 세션의 화면이 30초 동안 통째로 멈춘 것처럼 보이고, 그 사이 밀린
|
|
760
|
-
// 출력이 더 쌓여서 회복도 늦어진다.
|
|
761
|
-
|
|
851
|
+
// 출력이 더 쌓여서 회복도 늦어진다.
|
|
852
|
+
//
|
|
853
|
+
// 10초도 여전히 길었다. 답을 못 받은 조각은 MAX_FRAMES_IN_FLIGHT(6) 자리를 물고 있는데, 그게
|
|
854
|
+
// 전부 타임아웃을 기다리면 그 세션은 **10초 동안 아무 화면도 못 보낸다**. 이 컴퓨터↔서버 왕복은
|
|
855
|
+
// 정상일 때 0.3초 안쪽이라, 4초면 느린 회선까지 충분히 품으면서 막혔을 때 회복이 두 배 빠르다.
|
|
856
|
+
await cb.realtime.connect({ userId: `agent-${crypto.randomUUID()}`, timeout: 4_000 });
|
|
762
857
|
const channel = await cb.realtime.subscribe(TERMINAL_CATEGORY);
|
|
763
858
|
// 이 채널은 앱의 모든 멤버가 공유하므로, 프레임 내용(data)은 세션별 키로 암호화해서 주고받는다.
|
|
764
859
|
// Claude Code 훅이 남긴 "대답 마쳤어요" 쪽지를 받는다 (notify.ts).
|
|
@@ -782,8 +877,14 @@ export async function runAgent(options) {
|
|
|
782
877
|
const session = sessions.get(frame.to);
|
|
783
878
|
if (!session || session.closing)
|
|
784
879
|
return;
|
|
785
|
-
// 폰에서 온 프레임 = 누군가 이 세션을 보고 있다는 뜻 (임시 터미널
|
|
880
|
+
// 폰에서 온 프레임 = 누군가 이 세션을 보고 있다는 뜻 (임시 터미널 정리·출력 게이트에 쓴다)
|
|
881
|
+
const wasUnwatched = !hasViewer(session);
|
|
786
882
|
session.viewerSeenAt = Date.now();
|
|
883
|
+
// 안 보는 동안에는 실시간 프레임을 만들지 않으므로(pty.onData 참고), 다시 보기 시작하면
|
|
884
|
+
// 그 사이 화면이 어떻게 바뀌었는지 알 길이 없다 — 지금 화면을 통째로 보내 맞춰준다.
|
|
885
|
+
// connect_request 는 아래에서 어차피 복원하므로 여기서 두 번 하지 않는다.
|
|
886
|
+
if (wasUnwatched && frame.kind !== 'connect_request')
|
|
887
|
+
void sendRestore(session);
|
|
787
888
|
if (frame.kind === 'connect_request') {
|
|
788
889
|
// 화면 보기는 승인 여부와 무관하게 항상 되므로, 승인을 묻기 전에 지금 화면부터 그대로 복원해준다
|
|
789
890
|
void sendRestore(session);
|