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