shellbase 0.13.1 → 0.13.3
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 +256 -40
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -38,7 +38,17 @@ const RESTORE_SCROLLBACK_LINES = 300;
|
|
|
38
38
|
// 화면 복원뿐 아니라 평소 출력도 반드시 이 크기로 나눠야 한다 — 한 덩이로 보내면 출력이 조금만 몰려도
|
|
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;
|
|
49
|
+
// 서버가 재는 기준이 예상과 다를 수도 있으니, "너무 크다" 고 거부당하면 스스로 절반씩 줄인다.
|
|
50
|
+
// 이 아래로는 줄이지 않는다 (더 줄여도 안 되면 크기 문제가 아니다).
|
|
51
|
+
const MIN_FRAME_BUDGET_BYTES = 8 * 1024;
|
|
42
52
|
// 전송이 막혀 밀린 출력이 이만큼을 넘으면, 밀린 것을 하나하나 따라 보내는 대신 지금 화면을 통째로
|
|
43
53
|
// 다시 보낸다 — 사용자가 보게 될 최종 화면은 어차피 같고, 훨씬 빨리 따라잡는다.
|
|
44
54
|
const MAX_PENDING_OUTPUT = 256 * 1024;
|
|
@@ -47,9 +57,30 @@ const MAX_PENDING_OUTPUT = 256 * 1024;
|
|
|
47
57
|
// 서버가 40회 연속 전송도 거부 없이 받아줬고 이 값이 타이핑 체감 지연의 주범이었음(입력·출력 양쪽에서
|
|
48
58
|
// 각각 최대 120ms). 30ms 로 낮춰 체감을 줄이고, 출력이 몰릴 때는 여전히 초당 33회로 묶인다.
|
|
49
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;
|
|
50
66
|
// 그래도 서버가 거부하면(RATE_LIMITED 등) 잠시 간격을 넉넉히 벌려서 재시도한다
|
|
51
67
|
const SEND_PENALTY_MS = 500;
|
|
52
68
|
const PENALTY_INTERVAL_MS = 250;
|
|
69
|
+
// 이 컴퓨터의 모든 세션이 실시간 통로(WebSocket) 하나를 함께 쓴다. 카테고리 한도는 **채널 전체로**
|
|
70
|
+
// 초당 100건인데, 위의 30ms 는 세션마다 따로 지키는 간격이라 세션이 넷이면 채널로는 초당 130건이
|
|
71
|
+
// 나간다 — 한도를 넘는 순간부터 서버가 거부하기 시작하고, 거부된 출력이 밀리면서 화면이 멈춘다.
|
|
72
|
+
// 그래서 채널 전체에도 간격을 둔다 (12ms = 초당 약 83건, 한도에 여유를 남긴 값).
|
|
73
|
+
const CHANNEL_MIN_GAP_MS = 12;
|
|
74
|
+
// 화면 복원 전에 "지금까지 나온 출력이 보이지 않는 터미널에 다 반영됐는지" 기다리는 횟수.
|
|
75
|
+
// 기다리는 동안 새 출력이 또 들어오면 한 번 더 기다린다 — 쉬지 않고 쏟아지는 세션에서 영영
|
|
76
|
+
// 못 기다리는 일이 없도록 상한을 둔다.
|
|
77
|
+
const RESTORE_DRAIN_TRIES = 5;
|
|
78
|
+
// 그 기다림의 상한. 무슨 일이 있어도 여기서 영영 멈추면 안 된다 — 멈추는 순간 그 세션은
|
|
79
|
+
// 그 뒤로 아무 화면도 못 보내고, 지금 고치려는 증상과 똑같아진다.
|
|
80
|
+
const DRAIN_TIMEOUT_MS = 500;
|
|
81
|
+
// 화면 복원이 끝내 실패했을 때 다시 시도하는 간격 (점점 늘린다). 복원은 "화면을 지우고 새로 그리기"
|
|
82
|
+
// 라서, 중간에 실패하면 폰에는 지워진 화면 = 새까만 화면만 남는다. 반드시 될 때까지 다시 보낸다.
|
|
83
|
+
const RESTORE_RETRY_MS = [200, 500, 1_200, 3_000, 6_000];
|
|
53
84
|
// 실수나 버그로 세션이 무한정 늘어나는 것을 막는 상한 (한 컴퓨터에서 이 이상 필요한 경우는 사실상 없음)
|
|
54
85
|
const MAX_SESSIONS = 20;
|
|
55
86
|
// 저장 중인 파일 조각을 모아두는 시간 — 중간에 폰이 끊기면 조용히 버린다
|
|
@@ -66,6 +97,24 @@ const IDLE_MS = 4_000; // 이만큼 조용하면 "끝났다" 로 본다
|
|
|
66
97
|
// 시간만 보면 "천천히 오래 타이핑한 것" 도 일하는 중으로 잘못 본다(글자마다 화면에 되비치므로).
|
|
67
98
|
// 실제 작업은 출력량이 비교가 안 되게 많아서(회전 표시·중간 결과·빌드 로그) 양으로 걸러낸다.
|
|
68
99
|
const BUSY_MIN_BYTES = 2048;
|
|
100
|
+
// 보낼 글을 프레임 크기(UTF-8 바이트)에 맞춰 조각낸다.
|
|
101
|
+
// 글자 중간에서 자르면 그 글자가 깨지므로(한글은 3바이트, 이모지는 4바이트) 반드시 글자 경계에서 자른다.
|
|
102
|
+
function chunkByBytes(text, budget) {
|
|
103
|
+
const buf = Buffer.from(text, 'utf8');
|
|
104
|
+
if (buf.length <= budget)
|
|
105
|
+
return text ? [text] : [];
|
|
106
|
+
const parts = [];
|
|
107
|
+
let start = 0;
|
|
108
|
+
while (start < buf.length) {
|
|
109
|
+
let end = Math.min(buf.length, start + budget);
|
|
110
|
+
// UTF-8 에서 이어지는 바이트는 10xxxxxx 로 시작한다 — 거기서 끊으면 글자가 반쪽이 된다
|
|
111
|
+
while (end > start + 1 && end < buf.length && (buf[end] & 0xc0) === 0x80)
|
|
112
|
+
end--;
|
|
113
|
+
parts.push(buf.toString('utf8', start, end));
|
|
114
|
+
start = end;
|
|
115
|
+
}
|
|
116
|
+
return parts;
|
|
117
|
+
}
|
|
69
118
|
// 폰의 폴더 탐색기를 열 때 "지금 셸이 있는 폴더"에서 시작하려면, 세션을 시작한 폴더가 아니라
|
|
70
119
|
// 셸 프로세스의 실제 작업 폴더를 봐야 한다(사용자가 cd 로 옮겨 다니므로). 리눅스는 /proc 로 바로 알 수 있고,
|
|
71
120
|
// 없는 OS(맥·윈도)에서는 세션 시작 폴더로 되돌아간다.
|
|
@@ -173,9 +222,26 @@ export async function runAgent(options) {
|
|
|
173
222
|
.filter((session) => !session.closing && !session.temporary)
|
|
174
223
|
.map((session) => ({ dir: session.cwd, name: session.name })));
|
|
175
224
|
}
|
|
225
|
+
// 이 컴퓨터의 모든 세션이 실시간 통로(WebSocket) 하나를 함께 쓴다 — 채널 전체의 전송 간격은
|
|
226
|
+
// 여기서 지킨다. 세션마다 따로 간격을 지켜도 세션이 여럿이면 채널로는 한도(초당 100건)를 넘고,
|
|
227
|
+
// 넘는 순간부터 서버가 거부하기 시작해 그 출력이 밀리고 화면이 멈춘다.
|
|
228
|
+
let nextSlotAt = 0;
|
|
229
|
+
const takeSendSlot = async () => {
|
|
230
|
+
const now = Date.now();
|
|
231
|
+
const at = Math.max(now, nextSlotAt);
|
|
232
|
+
nextSlotAt = at + CHANNEL_MIN_GAP_MS;
|
|
233
|
+
if (at > now)
|
|
234
|
+
await new Promise((resolve) => setTimeout(resolve, at - now));
|
|
235
|
+
};
|
|
236
|
+
// 프레임 하나를 실제로 부친다. 자리를 먼저 받아두고 나가기 때문에, **부른 순서가 곧 나가는 순서**다
|
|
237
|
+
// (그래서 아래에서 여러 조각을 한꺼번에 부쳐도 순서가 지켜진다).
|
|
238
|
+
const sendFrame = async (frame) => {
|
|
239
|
+
await takeSendSlot();
|
|
240
|
+
await channel.send(frame, { includeSelf: false });
|
|
241
|
+
};
|
|
176
242
|
const send = async (frame) => {
|
|
177
243
|
try {
|
|
178
|
-
await
|
|
244
|
+
await sendFrame(frame);
|
|
179
245
|
}
|
|
180
246
|
catch (err) {
|
|
181
247
|
console.error('전송 실패:', err.message);
|
|
@@ -188,50 +254,137 @@ export async function runAgent(options) {
|
|
|
188
254
|
session.sendChain = next.then(() => undefined, () => undefined);
|
|
189
255
|
return next;
|
|
190
256
|
};
|
|
257
|
+
// 실제로 쓰는 조각 크기. 서버가 "너무 크다" 고 거부하면 절반으로 줄여서 스스로 맞춘다 —
|
|
258
|
+
// 크기 때문에 거부당하는 것은 다시 보내도 똑같이 거부되므로(= 검은 화면이 영영 안 풀린다),
|
|
259
|
+
// 재시도 전에 반드시 크기를 줄여야 한다.
|
|
260
|
+
let frameBudget = FRAME_BUDGET_BYTES;
|
|
261
|
+
const shrinkFrameBudget = (why) => {
|
|
262
|
+
if (frameBudget <= MIN_FRAME_BUDGET_BYTES)
|
|
263
|
+
return false;
|
|
264
|
+
frameBudget = Math.max(MIN_FRAME_BUDGET_BYTES, Math.floor(frameBudget / 2));
|
|
265
|
+
console.error(`조각 크기를 ${Math.round(frameBudget / 1024)}KB 로 줄였어요 (${why})`);
|
|
266
|
+
return true;
|
|
267
|
+
};
|
|
268
|
+
// 지금까지 나온 출력이 '보이지 않는 터미널' 에 다 반영되기를 기다린다.
|
|
269
|
+
// (xterm 은 받은 글자를 곧바로가 아니라 조금 뒤에 처리한다 — 실측 확인)
|
|
270
|
+
const drainScreen = (session) => new Promise((resolve) => {
|
|
271
|
+
let done = false;
|
|
272
|
+
const finish = () => {
|
|
273
|
+
if (done)
|
|
274
|
+
return;
|
|
275
|
+
done = true;
|
|
276
|
+
resolve();
|
|
277
|
+
};
|
|
278
|
+
// 답이 없어도 반드시 풀린다
|
|
279
|
+
const timer = setTimeout(finish, DRAIN_TIMEOUT_MS);
|
|
280
|
+
try {
|
|
281
|
+
session.screen.write('', () => {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
finish();
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
clearTimeout(timer);
|
|
288
|
+
finish();
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
// 화면 조각들을 순서대로 부치고, **앞에서부터 확실히 도착한 조각 수**를 돌려준다.
|
|
292
|
+
// 조각마다 서버 응답을 하나씩 기다리면 "조각 수 × 왕복시간" 이 그대로 지연이 된다. 화면 복원은
|
|
293
|
+
// 먼저 화면을 지우고 새로 그리는 방식이라, 그 지연이 통째로 **새까만 화면**으로 보인다
|
|
294
|
+
// (세션을 전환할 때마다 검은 화면이 몇 초씩 남다가 뒤늦게 돌아오던 원인).
|
|
295
|
+
// 통로는 부친 순서대로 내보내므로, 한꺼번에 부치고 결과만 모아서 확인한다.
|
|
296
|
+
const sendParts = async (session, parts) => {
|
|
297
|
+
const results = await Promise.allSettled(parts.map((part) => sendFrame({
|
|
298
|
+
kind: 'output',
|
|
299
|
+
to: session.deviceId,
|
|
300
|
+
data: encryptFrame(part, session.frameKey),
|
|
301
|
+
})));
|
|
302
|
+
let sent = 0;
|
|
303
|
+
while (sent < results.length && results[sent].status === 'fulfilled')
|
|
304
|
+
sent += 1;
|
|
305
|
+
if (sent < results.length) {
|
|
306
|
+
// 중간이 하나라도 빠지면 그 뒤는 순서를 믿을 수 없으므로 거기서부터 전부 다시 보낸다
|
|
307
|
+
const reason = results[sent].reason;
|
|
308
|
+
const message = String(reason?.message ?? reason);
|
|
309
|
+
console.error(`출력 전송 실패 (${sent}/${parts.length} 조각만 나감):`, message);
|
|
310
|
+
if (/too large|TOO_LARGE|size|크기/i.test(message))
|
|
311
|
+
shrinkFrameBudget(message);
|
|
312
|
+
session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
|
|
313
|
+
}
|
|
314
|
+
return sent;
|
|
315
|
+
};
|
|
191
316
|
const flush = async (session) => {
|
|
192
317
|
session.pendingFlush = null;
|
|
193
|
-
|
|
318
|
+
// 복원 중에는 끼어들지 않는다 — 복원은 "화면을 지우고 통째로 다시 그리기" 라서, 그 사이에
|
|
319
|
+
// 살아 있는 출력이 섞이면 폰 화면이 겹쳐 그려진다. 복원이 끝나면 그때 이어서 보낸다.
|
|
320
|
+
if (session.flushing || session.restoring || !session.outBuffer)
|
|
194
321
|
return;
|
|
195
|
-
const
|
|
322
|
+
const pending = session.outBuffer;
|
|
196
323
|
session.outBuffer = '';
|
|
197
324
|
// 전송이 계속 막혀 밀린 양이 감당 못 할 만큼 커졌으면, 밀린 것을 하나하나 따라 보내는 대신
|
|
198
325
|
// 지금 화면을 통째로 다시 보낸다. 화면 상태는 이쪽(session.screen)에 그대로 들고 있다.
|
|
199
|
-
if (
|
|
200
|
-
console.error(`밀린 출력이 너무 많아요(${Math.round(
|
|
326
|
+
if (Buffer.byteLength(pending, 'utf8') > MAX_PENDING_OUTPUT) {
|
|
327
|
+
console.error(`밀린 출력이 너무 많아요(${Math.round(Buffer.byteLength(pending, 'utf8') / 1024)}KB) — 화면을 통째로 다시 보낼게요.`);
|
|
201
328
|
if (!session.closing)
|
|
202
329
|
void sendRestore(session);
|
|
203
330
|
return;
|
|
204
331
|
}
|
|
205
332
|
session.flushing = true;
|
|
206
333
|
session.lastSentAt = Date.now();
|
|
334
|
+
// 부치는 *순서*만 이 줄에서 지키고, **서버 답(ack)은 기다리지 않는다**.
|
|
335
|
+
// 예전에는 조각 하나를 부치고 답이 올 때까지(왕복 276ms) 다음 조각을 붙들고 있었다.
|
|
336
|
+
// 그래서 출력이 열 조각으로 나뉘면 2.7초 뒤에야 마지막이 도착했고, 화면에는 한참 아무것도
|
|
337
|
+
// 없다가 한꺼번에 쏟아지는 것처럼 보였다 — "타자가 2초 뒤에 몰아서 보인다" 의 정체.
|
|
207
338
|
await enqueueSend(session, async () => {
|
|
208
339
|
try {
|
|
209
340
|
// 프레임 한도를 넘지 않도록 나눠 보낸다 (한 덩이로 보내면 통째로 거부돼 화면이 멈춘다)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
341
|
+
const parts = chunkByBytes(pending, frameBudget);
|
|
342
|
+
session.inFlight += parts.length;
|
|
343
|
+
// send() 를 부르는 순간 이미 통로로 나간다. 부른 순서 = 나가는 순서라서(실측 확인)
|
|
344
|
+
// 답을 기다리지 않아도 조각이 뒤섞이지 않는다.
|
|
345
|
+
const results = parts.map((part) => sendFrame({
|
|
346
|
+
kind: 'output',
|
|
347
|
+
to: session.deviceId,
|
|
348
|
+
data: encryptFrame(part, session.frameKey),
|
|
349
|
+
}));
|
|
350
|
+
void Promise.allSettled(results)
|
|
351
|
+
.then((settled) => {
|
|
352
|
+
let sent = 0;
|
|
353
|
+
while (sent < settled.length && settled[sent].status === 'fulfilled')
|
|
354
|
+
sent += 1;
|
|
355
|
+
if (sent >= settled.length)
|
|
356
|
+
return;
|
|
357
|
+
const reason = settled[sent].reason;
|
|
358
|
+
const message = String(reason?.message ?? reason);
|
|
359
|
+
console.error(`출력 전송 실패 (${sent}/${parts.length} 조각만 나감):`, message);
|
|
360
|
+
if (/too large|TOO_LARGE|size|크기/i.test(message))
|
|
361
|
+
shrinkFrameBudget(message);
|
|
362
|
+
session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
|
|
363
|
+
// 못 간 것을 다시 보내는 대신 **화면을 통째로 다시 보낸다**.
|
|
364
|
+
// 답을 기다리지 않고 잇달아 부치기 때문에, 실패를 알게 된 시점에는 그 뒤의 출력이
|
|
365
|
+
// 이미 나가 있을 수 있다 — 거기에 옛 조각을 뒤늦게 끼워 넣으면 화면이 뒤섞인다.
|
|
366
|
+
// 지금 화면이 어떻게 생겼는지는 session.screen 이 정확히 들고 있어서,
|
|
367
|
+
// 통째로 다시 보내면 빠지지도 겹치지도 않는다.
|
|
368
|
+
if (!session.closing)
|
|
369
|
+
void sendRestore(session);
|
|
370
|
+
})
|
|
371
|
+
.finally(() => {
|
|
372
|
+
session.inFlight -= parts.length;
|
|
373
|
+
// 답을 기다리느라 모아둔 것이 있으면 이어서 보낸다
|
|
374
|
+
if (session.outBuffer && !session.closing && !session.restoring)
|
|
375
|
+
scheduleFlush(session);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
// 여기서 새어 나간 오류는 아무도 받아주지 않아 프로세스가 통째로 죽는다 — 반드시 잡는다
|
|
380
|
+
console.error('출력을 보내다 예상 못 한 오류:', err.message);
|
|
228
381
|
}
|
|
229
382
|
finally {
|
|
230
383
|
// 무슨 일이 있어도 표시는 반드시 푼다 — 여기 걸린 채로 남으면 그 세션은 그 뒤로
|
|
231
|
-
// 아무 화면도 못 보낸다 (
|
|
384
|
+
// 아무 화면도 못 보낸다 (검은 화면의 원인 중 하나)
|
|
232
385
|
session.flushing = false;
|
|
233
|
-
//
|
|
234
|
-
if (session.outBuffer && !session.closing)
|
|
386
|
+
// 보내는 사이에 새로 나온 것이 있으면 곧바로 이어서 보낸다
|
|
387
|
+
if (session.outBuffer && !session.closing && !session.restoring)
|
|
235
388
|
scheduleFlush(session);
|
|
236
389
|
}
|
|
237
390
|
});
|
|
@@ -244,6 +397,11 @@ export async function runAgent(options) {
|
|
|
244
397
|
session.restoreAgain = true;
|
|
245
398
|
return;
|
|
246
399
|
}
|
|
400
|
+
// 다시 보내려고 걸어둔 시계가 있으면 지금 보내는 것으로 대신한다
|
|
401
|
+
if (session.restoreRetry !== null) {
|
|
402
|
+
clearTimeout(session.restoreRetry);
|
|
403
|
+
session.restoreRetry = null;
|
|
404
|
+
}
|
|
247
405
|
session.restoring = true;
|
|
248
406
|
try {
|
|
249
407
|
do {
|
|
@@ -252,15 +410,27 @@ export async function runAgent(options) {
|
|
|
252
410
|
return;
|
|
253
411
|
// 지금 화면 상태를 통째로 보낼 참이다. 아직 못 보낸 출력(outBuffer)은 이미 그 화면 안에
|
|
254
412
|
// 들어 있다 — pty.onData 가 outBuffer 와 screen 에 같이 넣기 때문. 그대로 두면 복원 직후
|
|
255
|
-
// 같은 내용이 한 번 더 나가서 화면에 두 번 찍힌다.
|
|
256
|
-
// (아래 await 전까지는 끊기지 않고 실행되므로, 버리는 것과 찍어내는 것 사이에 새 출력이
|
|
257
|
-
// 끼어들 틈이 없다)
|
|
413
|
+
// 같은 내용이 한 번 더 나가서 화면에 두 번 찍힌다. 아래에서 버린다.
|
|
258
414
|
if (session.pendingFlush !== null) {
|
|
259
415
|
clearTimeout(session.pendingFlush);
|
|
260
416
|
session.pendingFlush = null;
|
|
261
417
|
}
|
|
418
|
+
// 다만 '보이지 않는 터미널'(session.screen)은 글자를 **받자마자 반영하지 않는다** —
|
|
419
|
+
// 조금 뒤에 몰아서 처리한다(실측 확인). 그래서 방금 나온 출력은 아직 화면에 없는데,
|
|
420
|
+
// 그 상태로 스냅숏을 뜨면서 outBuffer 까지 비우면 그 출력은 스냅숏에도 없고 보내지도
|
|
421
|
+
// 않아 **영영 사라진다**. 세션을 옮기거나 키보드가 열릴 때마다(=크기 변경 → 복원)
|
|
422
|
+
// 마지막 몇 줄이 빠진 화면이 남던 원인이고, 그래서 "화면이 멈춘 것처럼" 보였다.
|
|
423
|
+
// 빈 글자를 하나 부쳐 그 처리가 끝나기를 기다리면, 그 앞의 것은 전부 반영된 뒤다.
|
|
424
|
+
for (let tries = 0; tries < RESTORE_DRAIN_TRIES; tries++) {
|
|
425
|
+
const before = session.outBuffer.length;
|
|
426
|
+
await drainScreen(session);
|
|
427
|
+
if (session.closing)
|
|
428
|
+
return;
|
|
429
|
+
// 기다리는 사이 새로 들어온 게 없으면, 지금 outBuffer 에 있는 것은 전부 화면에 들어갔다
|
|
430
|
+
if (session.outBuffer.length === before)
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
262
433
|
session.outBuffer = '';
|
|
263
|
-
session.flushing = false;
|
|
264
434
|
let snapshot;
|
|
265
435
|
try {
|
|
266
436
|
snapshot = session.serializer.serialize({ scrollback: RESTORE_SCROLLBACK_LINES });
|
|
@@ -271,22 +441,58 @@ export async function runAgent(options) {
|
|
|
271
441
|
}
|
|
272
442
|
// 화면을 먼저 지우고(2J·3J) 복원 내용을 보내야, 폰에 남아있던 예전 화면과 섞이지 않는다
|
|
273
443
|
const payload = `\x1b[H\x1b[2J\x1b[3J${snapshot}`;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
444
|
+
const parts = chunkByBytes(payload, frameBudget);
|
|
445
|
+
const sent = await enqueueSend(session, () => sendParts(session, parts));
|
|
446
|
+
if (sent < parts.length) {
|
|
447
|
+
// 화면을 지우라는 첫 조각만 도착하고 내용이 끊긴 상태 = 폰에는 **새까만 화면**만 남는다.
|
|
448
|
+
// 절대 이대로 두면 안 되므로 조금 뒤 통째로 다시 보낸다.
|
|
449
|
+
scheduleRestoreRetry(session);
|
|
450
|
+
return;
|
|
281
451
|
}
|
|
452
|
+
session.restoreFails = 0;
|
|
282
453
|
} while (session.restoreAgain);
|
|
283
454
|
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
// 여기서 새어 나간 오류는 아무도 받아주지 않아 프로세스가 통째로 죽는다 — 반드시 잡고,
|
|
457
|
+
// 화면은 비어 있을 수 있으니 조금 뒤 다시 보낸다
|
|
458
|
+
console.error('화면 복원 중 예상 못 한 오류:', err.message);
|
|
459
|
+
scheduleRestoreRetry(session);
|
|
460
|
+
}
|
|
284
461
|
finally {
|
|
285
462
|
session.restoring = false;
|
|
463
|
+
// 복원을 보내는 동안 새로 나온 출력은 위 스냅숏에 없다 — 이제 이어서 보낸다
|
|
464
|
+
if (session.outBuffer && !session.closing)
|
|
465
|
+
scheduleFlush(session);
|
|
286
466
|
}
|
|
287
467
|
};
|
|
468
|
+
// 복원이 중간에 끊겼을 때 다시 보내기. 폰이 아예 꺼졌을 수도 있으니 무한정 매달리지는 않는다
|
|
469
|
+
// (폰 쪽에도 "화면이 비어 있으면 다시 달라고 조르는" 안전망이 있다).
|
|
470
|
+
const scheduleRestoreRetry = (session) => {
|
|
471
|
+
if (session.closing || session.restoreRetry !== null)
|
|
472
|
+
return;
|
|
473
|
+
if (session.restoreFails >= RESTORE_RETRY_MS.length + 1) {
|
|
474
|
+
console.error(`화면 복원을 여러 번 시도했지만 계속 실패해요 ("${session.name}") — 폰에서 새로고침해 주세요.`);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const wait = RESTORE_RETRY_MS[Math.min(session.restoreFails, RESTORE_RETRY_MS.length - 1)];
|
|
478
|
+
session.restoreFails += 1;
|
|
479
|
+
// 두 번 연달아 실패하면 이유가 뭐라고 적혀 있든 크기부터 의심한다 — 같은 크기로 계속
|
|
480
|
+
// 다시 보내봐야 같은 이유로 막히고, 그동안 폰은 계속 새까만 화면이다.
|
|
481
|
+
if (session.restoreFails >= 2)
|
|
482
|
+
shrinkFrameBudget('화면 복원이 거듭 실패');
|
|
483
|
+
console.error(`화면 복원이 중간에 끊겼어요 ("${session.name}") — ${wait}ms 뒤 다시 보낼게요.`);
|
|
484
|
+
session.restoreRetry = setTimeout(() => {
|
|
485
|
+
session.restoreRetry = null;
|
|
486
|
+
if (!session.closing)
|
|
487
|
+
void sendRestore(session);
|
|
488
|
+
}, wait);
|
|
489
|
+
};
|
|
288
490
|
const scheduleFlush = (session) => {
|
|
289
|
-
if (session.pendingFlush !== null || session.flushing)
|
|
491
|
+
if (session.pendingFlush !== null || session.flushing || session.restoring)
|
|
492
|
+
return;
|
|
493
|
+
// 답을 못 받은 조각이 너무 많이 쌓였으면 잠시 모아둔다 — 하나가 돌아오면 그때 이어서 보낸다
|
|
494
|
+
// (그 사이 나온 출력은 한 덩이로 합쳐져서, 막힌 상황에서도 조각 수가 늘지 않는다)
|
|
495
|
+
if (session.inFlight >= MAX_FRAMES_IN_FLIGHT)
|
|
290
496
|
return;
|
|
291
497
|
const interval = Date.now() < session.sendPenaltyUntil ? PENALTY_INTERVAL_MS : MIN_SEND_INTERVAL_MS;
|
|
292
498
|
const wait = Math.max(0, interval - (Date.now() - session.lastSentAt));
|
|
@@ -408,9 +614,12 @@ export async function runAgent(options) {
|
|
|
408
614
|
approvalInFlight: false,
|
|
409
615
|
outBuffer: '',
|
|
410
616
|
flushing: false,
|
|
617
|
+
inFlight: 0,
|
|
411
618
|
sendChain: Promise.resolve(),
|
|
412
619
|
restoring: false,
|
|
413
620
|
restoreAgain: false,
|
|
621
|
+
restoreRetry: null,
|
|
622
|
+
restoreFails: 0,
|
|
414
623
|
lastSentAt: 0,
|
|
415
624
|
sendPenaltyUntil: 0,
|
|
416
625
|
pendingFlush: null,
|
|
@@ -465,6 +674,8 @@ export async function runAgent(options) {
|
|
|
465
674
|
session.closing = true;
|
|
466
675
|
if (session.pendingFlush !== null)
|
|
467
676
|
clearTimeout(session.pendingFlush);
|
|
677
|
+
if (session.restoreRetry !== null)
|
|
678
|
+
clearTimeout(session.restoreRetry);
|
|
468
679
|
if (session.idleTimer !== null)
|
|
469
680
|
clearTimeout(session.idleTimer);
|
|
470
681
|
await flush(session);
|
|
@@ -507,6 +718,8 @@ export async function runAgent(options) {
|
|
|
507
718
|
session.closing = true;
|
|
508
719
|
if (session.pendingFlush !== null)
|
|
509
720
|
clearTimeout(session.pendingFlush);
|
|
721
|
+
if (session.restoreRetry !== null)
|
|
722
|
+
clearTimeout(session.restoreRetry);
|
|
510
723
|
if (session.idleTimer !== null)
|
|
511
724
|
clearTimeout(session.idleTimer);
|
|
512
725
|
await flush(session);
|
|
@@ -539,7 +752,10 @@ export async function runAgent(options) {
|
|
|
539
752
|
}
|
|
540
753
|
// 실시간 연결은 프로세스에 하나만 — userId 는 UUID 형태를 "인증된 멤버 전용"으로 취급해서 거부하므로
|
|
541
754
|
// 접두사를 붙여 UUID 로 보이지 않게 만든다. 프레임 배분은 userId 가 아니라 frame.to 로 한다.
|
|
542
|
-
|
|
755
|
+
// timeout 은 "보낸 것에 서버가 답하기를 기다리는 시간" 이기도 하다. 기본값 30초는 터미널에는
|
|
756
|
+
// 너무 길다 — 한 번 막히면 그 세션의 화면이 30초 동안 통째로 멈춘 것처럼 보이고, 그 사이 밀린
|
|
757
|
+
// 출력이 더 쌓여서 회복도 늦어진다. 10초면 넉넉하면서 회복은 세 배 빠르다.
|
|
758
|
+
await cb.realtime.connect({ userId: `agent-${crypto.randomUUID()}`, timeout: 10_000 });
|
|
543
759
|
const channel = await cb.realtime.subscribe(TERMINAL_CATEGORY);
|
|
544
760
|
// 이 채널은 앱의 모든 멤버가 공유하므로, 프레임 내용(data)은 세션별 키로 암호화해서 주고받는다.
|
|
545
761
|
// Claude Code 훅이 남긴 "대답 마쳤어요" 쪽지를 받는다 (notify.ts).
|