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 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
- // 프레임 하나에 들어갈 수 있는 크기가 제한돼 있어서(실측 16KB 성공/64KB 실패) 나눠 보낸다
38
- const RESTORE_CHUNK = 12 * 1024;
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 channel.send(frame, { includeSelf: false });
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
- if (!session.outBuffer)
312
+ // 복원 중에는 끼어들지 않는다 — 복원은 "화면을 지우고 통째로 다시 그리기" 라서, 그 사이에
313
+ // 살아 있는 출력이 섞이면 폰 화면이 겹쳐 그려진다. 복원이 끝나면 그때 이어서 보낸다.
314
+ if (session.flushing || session.restoring || !session.outBuffer)
188
315
  return;
189
- const chunk = session.outBuffer;
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
- await channel.send({
195
- kind: 'output',
196
- to: session.deviceId,
197
- data: encryptFrame(chunk, session.frameKey),
198
- }, { includeSelf: false });
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
- session.outBuffer = chunk + session.outBuffer;
203
- session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
204
- console.error('출력 전송 실패, 다시 시도해요:', err.message);
205
- if (!session.closing)
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
- for (let i = 0; i < payload.length; i += RESTORE_CHUNK) {
245
- const part = payload.slice(i, i + RESTORE_CHUNK);
246
- await enqueueSend(session, () => send({
247
- kind: 'output',
248
- to: session.deviceId,
249
- data: encryptFrame(part, session.frameKey),
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
- await cb.realtime.connect({ userId: `agent-${crypto.randomUUID()}` });
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
  // (정말 필요하면 터미널에서 직접 지우면 된다).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellbase",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {