shellbase 0.13.0 → 0.13.1

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,14 @@ 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 실패) 나눠 보낸다
37
+ // 프레임 하나에 들어갈 수 있는 크기가 제한돼 있어서(카테고리 한도 64KB, 암호화하며 더 커짐) 나눠 보낸다.
38
+ // 화면 복원뿐 아니라 평소 출력도 반드시 이 크기로 나눠야 한다 — 한 덩이로 보내면 출력이 조금만 몰려도
39
+ // (빌드 로그·Claude Code 전체 다시 그리기 등) 프레임이 한도를 넘어 통째로 거부되고, 그 세션은 그 뒤로
40
+ // 아무 화면도 못 받는다. 폰에는 "검은 화면에 글자 하나 없는" 상태로 보인다 (제보).
38
41
  const RESTORE_CHUNK = 12 * 1024;
42
+ // 전송이 막혀 밀린 출력이 이만큼을 넘으면, 밀린 것을 하나하나 따라 보내는 대신 지금 화면을 통째로
43
+ // 다시 보낸다 — 사용자가 보게 될 최종 화면은 어차피 같고, 훨씬 빨리 따라잡는다.
44
+ const MAX_PENDING_OUTPUT = 256 * 1024;
39
45
  // 출력 전송 최소 간격. "마지막 전송 후 이 간격이 지났으면 바로 보내고, 아니면 그만큼만 기다렸다 모아서
40
46
  // 보낸다"(leading-edge 스로틀). 예전엔 문서의 "최소 100ms" 를 보수적으로 잡아 120ms 로 뒀는데, 실측해보니
41
47
  // 서버가 40회 연속 전송도 거부 없이 받아줬고 이 값이 타이핑 체감 지연의 주범이었음(입력·출력 양쪽에서
@@ -184,25 +190,48 @@ export async function runAgent(options) {
184
190
  };
185
191
  const flush = async (session) => {
186
192
  session.pendingFlush = null;
187
- if (!session.outBuffer)
193
+ if (session.flushing || !session.outBuffer)
188
194
  return;
189
195
  const chunk = session.outBuffer;
190
196
  session.outBuffer = '';
197
+ // 전송이 계속 막혀 밀린 양이 감당 못 할 만큼 커졌으면, 밀린 것을 하나하나 따라 보내는 대신
198
+ // 지금 화면을 통째로 다시 보낸다. 화면 상태는 이쪽(session.screen)에 그대로 들고 있다.
199
+ if (chunk.length > MAX_PENDING_OUTPUT) {
200
+ console.error(`밀린 출력이 너무 많아요(${Math.round(chunk.length / 1024)}KB) — 화면을 통째로 다시 보낼게요.`);
201
+ if (!session.closing)
202
+ void sendRestore(session);
203
+ return;
204
+ }
205
+ session.flushing = true;
191
206
  session.lastSentAt = Date.now();
192
207
  await enqueueSend(session, async () => {
193
208
  try {
194
- await channel.send({
195
- kind: 'output',
196
- to: session.deviceId,
197
- data: encryptFrame(chunk, session.frameKey),
198
- }, { includeSelf: false });
209
+ // 프레임 한도를 넘지 않도록 나눠 보낸다 (한 덩이로 보내면 통째로 거부돼 화면이 멈춘다)
210
+ for (let i = 0; i < chunk.length; i += RESTORE_CHUNK) {
211
+ const part = chunk.slice(i, i + RESTORE_CHUNK);
212
+ try {
213
+ await channel.send({
214
+ kind: 'output',
215
+ to: session.deviceId,
216
+ data: encryptFrame(part, session.frameKey),
217
+ }, { includeSelf: false });
218
+ }
219
+ catch (err) {
220
+ // 실패한 출력을 그냥 버리면 폰 화면이 깨진 채로 남으니, 실패한 자리부터 뒤쪽 전부를
221
+ // 다음 전송 앞에 다시 붙여 순서대로 재시도한다
222
+ session.outBuffer = chunk.slice(i) + session.outBuffer;
223
+ session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
224
+ console.error('출력 전송 실패, 다시 시도해요:', err.message);
225
+ break;
226
+ }
227
+ }
199
228
  }
200
- 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)
229
+ finally {
230
+ // 무슨 일이 있어도 표시는 반드시 푼다 여기 걸린 채로 남으면 세션은 뒤로
231
+ // 아무 화면도 보낸다 (지금 고치려는 바로 그 증상)
232
+ session.flushing = false;
233
+ // 남은 것(재시도분·보내는 사이에 새로 나온 것)이 있으면 이어서 보낸다
234
+ if (session.outBuffer && !session.closing)
206
235
  scheduleFlush(session);
207
236
  }
208
237
  });
@@ -231,6 +260,7 @@ export async function runAgent(options) {
231
260
  session.pendingFlush = null;
232
261
  }
233
262
  session.outBuffer = '';
263
+ session.flushing = false;
234
264
  let snapshot;
235
265
  try {
236
266
  snapshot = session.serializer.serialize({ scrollback: RESTORE_SCROLLBACK_LINES });
@@ -256,7 +286,7 @@ export async function runAgent(options) {
256
286
  }
257
287
  };
258
288
  const scheduleFlush = (session) => {
259
- if (session.pendingFlush !== null)
289
+ if (session.pendingFlush !== null || session.flushing)
260
290
  return;
261
291
  const interval = Date.now() < session.sendPenaltyUntil ? PENALTY_INTERVAL_MS : MIN_SEND_INTERVAL_MS;
262
292
  const wait = Math.max(0, interval - (Date.now() - session.lastSentAt));
@@ -377,6 +407,7 @@ export async function runAgent(options) {
377
407
  inputApproved: false,
378
408
  approvalInFlight: false,
379
409
  outBuffer: '',
410
+ flushing: false,
380
411
  sendChain: Promise.resolve(),
381
412
  restoring: false,
382
413
  restoreAgain: false,
@@ -792,7 +823,7 @@ export async function runAgent(options) {
792
823
  });
793
824
  return;
794
825
  }
795
- // 폴더 찾기에서 이름 바꾸기 / 지우기. 어느 쪽이든 끝나면 **새 목록**을 돌려줘서
826
+ // 폴더 찾기에서 새로 만들기 / 이름 바꾸기 / 지우기. 무엇이든 끝나면 **새 목록**을 돌려줘서
796
827
  // 폰 화면이 곧바로 최신 상태가 되게 한다 (실패 사유도 그 목록에 실어 보낸다).
797
828
  if (frame.kind === 'path_action') {
798
829
  const control = readControl(frame.data, session.frameKey);
@@ -810,6 +841,14 @@ export async function runAgent(options) {
810
841
  if (!failure)
811
842
  console.log(`📱 폰에서 이름을 바꿨어요: ${name} → ${to} (${dir})`);
812
843
  }
844
+ else if (control.op === 'create') {
845
+ // 「+ 새로 만들기」 — 빈 파일이나 빈 폴더 하나. 이미 있는 이름이면 만들지 않는다.
846
+ const kind = control.kind === 'folder' ? 'folder' : 'file';
847
+ failure = createEntry(dir, name, kind).error;
848
+ if (!failure) {
849
+ console.log(`📱 폰에서 ${kind === 'folder' ? '폴더' : '파일'}를 만들었어요: ${name} (${dir})`);
850
+ }
851
+ }
813
852
  else if (control.op === 'delete') {
814
853
  const recursive = control.recursive === true;
815
854
  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.1",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {