shellbase 0.13.0 → 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 +247 -29
- package/dist/browse.js +22 -0
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -15,7 +15,7 @@ import { encryptFrame, decryptFrame, generateFrameKey } 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';
|
|
18
|
-
import { listDirs, deleteEntry, renameEntry } from './browse.js';
|
|
18
|
+
import { listDirs, createEntry, deleteEntry, renameEntry } from './browse.js';
|
|
19
19
|
import { readTextFile, writeTextFile, chunkContent } from './files.js';
|
|
20
20
|
import { AUDIO_CHUNK_TTL_MS, MAX_AUDIO_BASE64, STT_SETUP_HINT, findSttServer, transcribe, } from './stt.js';
|
|
21
21
|
import { loadOpenSessions, saveOpenSessions } from './open-sessions.js';
|
|
@@ -34,8 +34,24 @@ const IMAGE_SWEEP_MS = 6 * 60 * 60 * 1000;
|
|
|
34
34
|
const APPROVAL_TIMEOUT_MS = 30_000;
|
|
35
35
|
// 복원해서 보낼 때 함께 보낼 이전 줄 수 (화면 위로 올려볼 수 있는 분량)
|
|
36
36
|
const RESTORE_SCROLLBACK_LINES = 300;
|
|
37
|
-
// 프레임 하나에 들어갈 수 있는 크기가 제한돼 있어서(
|
|
38
|
-
|
|
37
|
+
// 프레임 하나에 들어갈 수 있는 크기가 제한돼 있어서(카테고리 한도 64KB, 암호화하며 더 커짐) 나눠 보낸다.
|
|
38
|
+
// 화면 복원뿐 아니라 평소 출력도 반드시 이 크기로 나눠야 한다 — 한 덩이로 보내면 출력이 조금만 몰려도
|
|
39
|
+
// (빌드 로그·Claude Code 전체 다시 그리기 등) 프레임이 한도를 넘어 통째로 거부되고, 그 세션은 그 뒤로
|
|
40
|
+
// 아무 화면도 못 받는다. 폰에는 "검은 화면에 글자 하나 없는" 상태로 보인다 (제보).
|
|
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;
|
|
52
|
+
// 전송이 막혀 밀린 출력이 이만큼을 넘으면, 밀린 것을 하나하나 따라 보내는 대신 지금 화면을 통째로
|
|
53
|
+
// 다시 보낸다 — 사용자가 보게 될 최종 화면은 어차피 같고, 훨씬 빨리 따라잡는다.
|
|
54
|
+
const MAX_PENDING_OUTPUT = 256 * 1024;
|
|
39
55
|
// 출력 전송 최소 간격. "마지막 전송 후 이 간격이 지났으면 바로 보내고, 아니면 그만큼만 기다렸다 모아서
|
|
40
56
|
// 보낸다"(leading-edge 스로틀). 예전엔 문서의 "최소 100ms" 를 보수적으로 잡아 120ms 로 뒀는데, 실측해보니
|
|
41
57
|
// 서버가 40회 연속 전송도 거부 없이 받아줬고 이 값이 타이핑 체감 지연의 주범이었음(입력·출력 양쪽에서
|
|
@@ -44,6 +60,21 @@ const MIN_SEND_INTERVAL_MS = 30;
|
|
|
44
60
|
// 그래도 서버가 거부하면(RATE_LIMITED 등) 잠시 간격을 넉넉히 벌려서 재시도한다
|
|
45
61
|
const SEND_PENALTY_MS = 500;
|
|
46
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];
|
|
47
78
|
// 실수나 버그로 세션이 무한정 늘어나는 것을 막는 상한 (한 컴퓨터에서 이 이상 필요한 경우는 사실상 없음)
|
|
48
79
|
const MAX_SESSIONS = 20;
|
|
49
80
|
// 저장 중인 파일 조각을 모아두는 시간 — 중간에 폰이 끊기면 조용히 버린다
|
|
@@ -60,6 +91,24 @@ const IDLE_MS = 4_000; // 이만큼 조용하면 "끝났다" 로 본다
|
|
|
60
91
|
// 시간만 보면 "천천히 오래 타이핑한 것" 도 일하는 중으로 잘못 본다(글자마다 화면에 되비치므로).
|
|
61
92
|
// 실제 작업은 출력량이 비교가 안 되게 많아서(회전 표시·중간 결과·빌드 로그) 양으로 걸러낸다.
|
|
62
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
|
+
}
|
|
63
112
|
// 폰의 폴더 탐색기를 열 때 "지금 셸이 있는 폴더"에서 시작하려면, 세션을 시작한 폴더가 아니라
|
|
64
113
|
// 셸 프로세스의 실제 작업 폴더를 봐야 한다(사용자가 cd 로 옮겨 다니므로). 리눅스는 /proc 로 바로 알 수 있고,
|
|
65
114
|
// 없는 OS(맥·윈도)에서는 세션 시작 폴더로 되돌아간다.
|
|
@@ -167,9 +216,26 @@ export async function runAgent(options) {
|
|
|
167
216
|
.filter((session) => !session.closing && !session.temporary)
|
|
168
217
|
.map((session) => ({ dir: session.cwd, name: session.name })));
|
|
169
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
|
+
};
|
|
170
236
|
const send = async (frame) => {
|
|
171
237
|
try {
|
|
172
|
-
await
|
|
238
|
+
await sendFrame(frame);
|
|
173
239
|
}
|
|
174
240
|
catch (err) {
|
|
175
241
|
console.error('전송 실패:', err.message);
|
|
@@ -182,27 +248,111 @@ export async function runAgent(options) {
|
|
|
182
248
|
session.sendChain = next.then(() => undefined, () => undefined);
|
|
183
249
|
return next;
|
|
184
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
|
+
};
|
|
185
310
|
const flush = async (session) => {
|
|
186
311
|
session.pendingFlush = null;
|
|
187
|
-
|
|
312
|
+
// 복원 중에는 끼어들지 않는다 — 복원은 "화면을 지우고 통째로 다시 그리기" 라서, 그 사이에
|
|
313
|
+
// 살아 있는 출력이 섞이면 폰 화면이 겹쳐 그려진다. 복원이 끝나면 그때 이어서 보낸다.
|
|
314
|
+
if (session.flushing || session.restoring || !session.outBuffer)
|
|
188
315
|
return;
|
|
189
|
-
const
|
|
316
|
+
const pending = session.outBuffer;
|
|
190
317
|
session.outBuffer = '';
|
|
318
|
+
// 전송이 계속 막혀 밀린 양이 감당 못 할 만큼 커졌으면, 밀린 것을 하나하나 따라 보내는 대신
|
|
319
|
+
// 지금 화면을 통째로 다시 보낸다. 화면 상태는 이쪽(session.screen)에 그대로 들고 있다.
|
|
320
|
+
if (Buffer.byteLength(pending, 'utf8') > MAX_PENDING_OUTPUT) {
|
|
321
|
+
console.error(`밀린 출력이 너무 많아요(${Math.round(Buffer.byteLength(pending, 'utf8') / 1024)}KB) — 화면을 통째로 다시 보낼게요.`);
|
|
322
|
+
if (!session.closing)
|
|
323
|
+
void sendRestore(session);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
session.flushing = true;
|
|
191
327
|
session.lastSentAt = Date.now();
|
|
192
328
|
await enqueueSend(session, async () => {
|
|
193
329
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
330
|
+
// 프레임 한도를 넘지 않도록 나눠 보낸다 (한 덩이로 보내면 통째로 거부돼 화면이 멈춘다)
|
|
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;
|
|
337
|
+
}
|
|
338
|
+
else if (!session.closing) {
|
|
339
|
+
// 여러 조각을 한꺼번에 부쳤는데 일부만 갔다. 못 간 것만 다시 보내면 이미 도착한 뒷조각이
|
|
340
|
+
// 두 번 그려질 수 있다 — 대신 화면을 통째로 다시 보낸다. 지금 화면이 어떻게 생겼는지는
|
|
341
|
+
// 이쪽(session.screen)이 정확히 들고 있어서, 빠지지도 겹치지도 않는다.
|
|
342
|
+
void sendRestore(session);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
199
345
|
}
|
|
200
346
|
catch (err) {
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
347
|
+
// 여기서 새어 나간 오류는 아무도 받아주지 않아 프로세스가 통째로 죽는다 — 반드시 잡는다
|
|
348
|
+
console.error('출력을 보내다 예상 못 한 오류:', err.message);
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
// 무슨 일이 있어도 표시는 반드시 푼다 — 여기 걸린 채로 남으면 그 세션은 그 뒤로
|
|
352
|
+
// 아무 화면도 못 보낸다 (검은 화면의 원인 중 하나)
|
|
353
|
+
session.flushing = false;
|
|
354
|
+
// 남은 것(재시도분·보내는 사이에 새로 나온 것)이 있으면 이어서 보낸다
|
|
355
|
+
if (session.outBuffer && !session.closing && !session.restoring)
|
|
206
356
|
scheduleFlush(session);
|
|
207
357
|
}
|
|
208
358
|
});
|
|
@@ -215,6 +365,11 @@ export async function runAgent(options) {
|
|
|
215
365
|
session.restoreAgain = true;
|
|
216
366
|
return;
|
|
217
367
|
}
|
|
368
|
+
// 다시 보내려고 걸어둔 시계가 있으면 지금 보내는 것으로 대신한다
|
|
369
|
+
if (session.restoreRetry !== null) {
|
|
370
|
+
clearTimeout(session.restoreRetry);
|
|
371
|
+
session.restoreRetry = null;
|
|
372
|
+
}
|
|
218
373
|
session.restoring = true;
|
|
219
374
|
try {
|
|
220
375
|
do {
|
|
@@ -223,13 +378,26 @@ export async function runAgent(options) {
|
|
|
223
378
|
return;
|
|
224
379
|
// 지금 화면 상태를 통째로 보낼 참이다. 아직 못 보낸 출력(outBuffer)은 이미 그 화면 안에
|
|
225
380
|
// 들어 있다 — pty.onData 가 outBuffer 와 screen 에 같이 넣기 때문. 그대로 두면 복원 직후
|
|
226
|
-
// 같은 내용이 한 번 더 나가서 화면에 두 번 찍힌다.
|
|
227
|
-
// (아래 await 전까지는 끊기지 않고 실행되므로, 버리는 것과 찍어내는 것 사이에 새 출력이
|
|
228
|
-
// 끼어들 틈이 없다)
|
|
381
|
+
// 같은 내용이 한 번 더 나가서 화면에 두 번 찍힌다. 아래에서 버린다.
|
|
229
382
|
if (session.pendingFlush !== null) {
|
|
230
383
|
clearTimeout(session.pendingFlush);
|
|
231
384
|
session.pendingFlush = null;
|
|
232
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
|
+
}
|
|
233
401
|
session.outBuffer = '';
|
|
234
402
|
let snapshot;
|
|
235
403
|
try {
|
|
@@ -241,22 +409,54 @@ export async function runAgent(options) {
|
|
|
241
409
|
}
|
|
242
410
|
// 화면을 먼저 지우고(2J·3J) 복원 내용을 보내야, 폰에 남아있던 예전 화면과 섞이지 않는다
|
|
243
411
|
const payload = `\x1b[H\x1b[2J\x1b[3J${snapshot}`;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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;
|
|
251
419
|
}
|
|
420
|
+
session.restoreFails = 0;
|
|
252
421
|
} while (session.restoreAgain);
|
|
253
422
|
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
// 여기서 새어 나간 오류는 아무도 받아주지 않아 프로세스가 통째로 죽는다 — 반드시 잡고,
|
|
425
|
+
// 화면은 비어 있을 수 있으니 조금 뒤 다시 보낸다
|
|
426
|
+
console.error('화면 복원 중 예상 못 한 오류:', err.message);
|
|
427
|
+
scheduleRestoreRetry(session);
|
|
428
|
+
}
|
|
254
429
|
finally {
|
|
255
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;
|
|
256
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);
|
|
257
457
|
};
|
|
258
458
|
const scheduleFlush = (session) => {
|
|
259
|
-
if (session.pendingFlush !== null)
|
|
459
|
+
if (session.pendingFlush !== null || session.flushing || session.restoring)
|
|
260
460
|
return;
|
|
261
461
|
const interval = Date.now() < session.sendPenaltyUntil ? PENALTY_INTERVAL_MS : MIN_SEND_INTERVAL_MS;
|
|
262
462
|
const wait = Math.max(0, interval - (Date.now() - session.lastSentAt));
|
|
@@ -377,9 +577,12 @@ export async function runAgent(options) {
|
|
|
377
577
|
inputApproved: false,
|
|
378
578
|
approvalInFlight: false,
|
|
379
579
|
outBuffer: '',
|
|
580
|
+
flushing: false,
|
|
380
581
|
sendChain: Promise.resolve(),
|
|
381
582
|
restoring: false,
|
|
382
583
|
restoreAgain: false,
|
|
584
|
+
restoreRetry: null,
|
|
585
|
+
restoreFails: 0,
|
|
383
586
|
lastSentAt: 0,
|
|
384
587
|
sendPenaltyUntil: 0,
|
|
385
588
|
pendingFlush: null,
|
|
@@ -434,6 +637,8 @@ export async function runAgent(options) {
|
|
|
434
637
|
session.closing = true;
|
|
435
638
|
if (session.pendingFlush !== null)
|
|
436
639
|
clearTimeout(session.pendingFlush);
|
|
640
|
+
if (session.restoreRetry !== null)
|
|
641
|
+
clearTimeout(session.restoreRetry);
|
|
437
642
|
if (session.idleTimer !== null)
|
|
438
643
|
clearTimeout(session.idleTimer);
|
|
439
644
|
await flush(session);
|
|
@@ -476,6 +681,8 @@ export async function runAgent(options) {
|
|
|
476
681
|
session.closing = true;
|
|
477
682
|
if (session.pendingFlush !== null)
|
|
478
683
|
clearTimeout(session.pendingFlush);
|
|
684
|
+
if (session.restoreRetry !== null)
|
|
685
|
+
clearTimeout(session.restoreRetry);
|
|
479
686
|
if (session.idleTimer !== null)
|
|
480
687
|
clearTimeout(session.idleTimer);
|
|
481
688
|
await flush(session);
|
|
@@ -508,7 +715,10 @@ export async function runAgent(options) {
|
|
|
508
715
|
}
|
|
509
716
|
// 실시간 연결은 프로세스에 하나만 — userId 는 UUID 형태를 "인증된 멤버 전용"으로 취급해서 거부하므로
|
|
510
717
|
// 접두사를 붙여 UUID 로 보이지 않게 만든다. 프레임 배분은 userId 가 아니라 frame.to 로 한다.
|
|
511
|
-
|
|
718
|
+
// timeout 은 "보낸 것에 서버가 답하기를 기다리는 시간" 이기도 하다. 기본값 30초는 터미널에는
|
|
719
|
+
// 너무 길다 — 한 번 막히면 그 세션의 화면이 30초 동안 통째로 멈춘 것처럼 보이고, 그 사이 밀린
|
|
720
|
+
// 출력이 더 쌓여서 회복도 늦어진다. 10초면 넉넉하면서 회복은 세 배 빠르다.
|
|
721
|
+
await cb.realtime.connect({ userId: `agent-${crypto.randomUUID()}`, timeout: 10_000 });
|
|
512
722
|
const channel = await cb.realtime.subscribe(TERMINAL_CATEGORY);
|
|
513
723
|
// 이 채널은 앱의 모든 멤버가 공유하므로, 프레임 내용(data)은 세션별 키로 암호화해서 주고받는다.
|
|
514
724
|
// Claude Code 훅이 남긴 "대답 마쳤어요" 쪽지를 받는다 (notify.ts).
|
|
@@ -792,7 +1002,7 @@ export async function runAgent(options) {
|
|
|
792
1002
|
});
|
|
793
1003
|
return;
|
|
794
1004
|
}
|
|
795
|
-
// 폴더 찾기에서 이름 바꾸기 / 지우기.
|
|
1005
|
+
// 폴더 찾기에서 새로 만들기 / 이름 바꾸기 / 지우기. 무엇이든 끝나면 **새 목록**을 돌려줘서
|
|
796
1006
|
// 폰 화면이 곧바로 최신 상태가 되게 한다 (실패 사유도 그 목록에 실어 보낸다).
|
|
797
1007
|
if (frame.kind === 'path_action') {
|
|
798
1008
|
const control = readControl(frame.data, session.frameKey);
|
|
@@ -810,6 +1020,14 @@ export async function runAgent(options) {
|
|
|
810
1020
|
if (!failure)
|
|
811
1021
|
console.log(`📱 폰에서 이름을 바꿨어요: ${name} → ${to} (${dir})`);
|
|
812
1022
|
}
|
|
1023
|
+
else if (control.op === 'create') {
|
|
1024
|
+
// 「+ 새로 만들기」 — 빈 파일이나 빈 폴더 하나. 이미 있는 이름이면 만들지 않는다.
|
|
1025
|
+
const kind = control.kind === 'folder' ? 'folder' : 'file';
|
|
1026
|
+
failure = createEntry(dir, name, kind).error;
|
|
1027
|
+
if (!failure) {
|
|
1028
|
+
console.log(`📱 폰에서 ${kind === 'folder' ? '폴더' : '파일'}를 만들었어요: ${name} (${dir})`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
813
1031
|
else if (control.op === 'delete') {
|
|
814
1032
|
const recursive = control.recursive === true;
|
|
815
1033
|
const result = deleteEntry(dir, name, { recursive, protect: liveCwd(session) });
|
package/dist/browse.js
CHANGED
|
@@ -36,6 +36,28 @@ export function renameEntry(dir, name, to) {
|
|
|
36
36
|
return { error: `이름을 바꾸지 못했어요: ${err.message}` };
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
// 새로 만들기 — 지금 보고 있는 폴더 안에 빈 파일이나 빈 폴더를 하나 만든다.
|
|
40
|
+
// 이미 있는 이름은 절대 덮어쓰지 않는다 (내용이 날아가면 되돌릴 수 없다).
|
|
41
|
+
export function createEntry(dir, name, kind) {
|
|
42
|
+
const target = childPath(dir, name);
|
|
43
|
+
if (!target)
|
|
44
|
+
return { error: '이름에 「/」 나 「..」 은 쓸 수 없어요.' };
|
|
45
|
+
if (fs.existsSync(target))
|
|
46
|
+
return { error: `"${name}" 는 이미 있어요.` };
|
|
47
|
+
try {
|
|
48
|
+
if (kind === 'folder') {
|
|
49
|
+
fs.mkdirSync(target);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// wx = 이미 있으면 실패 — 위에서 한 번 봤지만 그 사이에 생겼을 수도 있어서 한 겹 더 막는다
|
|
53
|
+
fs.writeFileSync(target, '', { flag: 'wx' });
|
|
54
|
+
}
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
return { error: `만들지 못했어요: ${err.message}` };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
39
61
|
// 지우기 — 파일은 바로, 폴더는 **비어 있을 때만**.
|
|
40
62
|
// 폰에서 한 번 잘못 누르면 되돌릴 수 없으므로, 안에 든 것까지 통째로 지우는 일은 하지 않는다
|
|
41
63
|
// (정말 필요하면 터미널에서 직접 지우면 된다).
|