shellbase 0.12.1 → 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);
@@ -801,16 +832,32 @@ export async function runAgent(options) {
801
832
  const dir = typeof control.dir === 'string' && control.dir ? control.dir : liveCwd(session);
802
833
  const name = typeof control.name === 'string' ? control.name : '';
803
834
  let failure;
835
+ // 내용물이 든 폴더는 바로 지우지 않고 개수만 돌려준다 — 폰이 한 번 더 물어본 뒤
836
+ // recursive 로 다시 부른다 (browse.ts 의 deleteEntry 설명 참고)
837
+ let pending;
804
838
  if (control.op === 'rename') {
805
839
  const to = typeof control.to === 'string' ? control.to : '';
806
840
  failure = renameEntry(dir, name, to).error;
807
841
  if (!failure)
808
842
  console.log(`📱 폰에서 이름을 바꿨어요: ${name} → ${to} (${dir})`);
809
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
+ }
810
852
  else if (control.op === 'delete') {
811
- failure = deleteEntry(dir, name).error;
812
- if (!failure)
813
- console.log(`📱 폰에서 지웠어요: ${name} (${dir})`);
853
+ const recursive = control.recursive === true;
854
+ const result = deleteEntry(dir, name, { recursive, protect: liveCwd(session) });
855
+ failure = result.error;
856
+ if (result.contains !== undefined)
857
+ pending = { name, contains: result.contains };
858
+ if (!failure && !pending) {
859
+ console.log(`📱 폰에서 지웠어요: ${name}${recursive ? ' (통째로)' : ''} (${dir})`);
860
+ }
814
861
  }
815
862
  else {
816
863
  failure = '알 수 없는 요청이에요.';
@@ -819,7 +866,11 @@ export async function runAgent(options) {
819
866
  void send({
820
867
  kind: 'dirs',
821
868
  to: session.deviceId,
822
- data: encryptFrame(JSON.stringify(failure ? { ...listing, error: failure } : listing), session.frameKey),
869
+ data: encryptFrame(JSON.stringify({
870
+ ...listing,
871
+ ...(failure ? { error: failure } : {}),
872
+ ...(pending ? { pending } : {}),
873
+ }), session.frameKey),
823
874
  });
824
875
  return;
825
876
  }
package/dist/browse.js CHANGED
@@ -36,10 +36,35 @@ 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
  // (정말 필요하면 터미널에서 직접 지우면 된다).
42
- export function deleteEntry(dir, name) {
64
+ // 지우기. 폴더 안에 뭐가 들어 있으면 바로 지우지 않고 `contains`(들어 있는 개수) 돌려준다 —
65
+ // 폰에서 "안에 N개가 들어 있어요. 통째로 지울까요?" 를 한 번 더 물어본 뒤 recursive 로 다시 부른다.
66
+ // (예전에는 여기서 그냥 거절했는데, 그러면 내용물 있는 폴더는 영영 정리할 수 없었다)
67
+ export function deleteEntry(dir, name, opts = {}) {
43
68
  const target = childPath(dir, name);
44
69
  if (!target)
45
70
  return { error: '지울 수 없는 이름이에요.' };
@@ -50,13 +75,17 @@ export function deleteEntry(dir, name) {
50
75
  catch {
51
76
  return { error: '이미 없어요.' };
52
77
  }
78
+ // 지금 터미널이 그 안에서 돌고 있는 폴더는 못 지운다 — 지우면 셸이 갈 곳을 잃는다
79
+ if (opts.protect && stat.isDirectory() && isInside(opts.protect, target)) {
80
+ return { error: `지금 터미널이 "${name}" 안에서 돌고 있어요. 밖으로 나온 뒤 지워주세요.` };
81
+ }
53
82
  try {
54
83
  if (stat.isDirectory()) {
55
84
  const inside = fs.readdirSync(target);
56
- if (inside.length > 0) {
57
- return { error: `"${name}" 안에 ${inside.length}개가 들어 있어서 여기서는 못 지워요.` };
85
+ if (inside.length > 0 && !opts.recursive) {
86
+ return { contains: inside.length };
58
87
  }
59
- fs.rmdirSync(target);
88
+ fs.rmSync(target, { recursive: true, force: true });
60
89
  return {};
61
90
  }
62
91
  fs.unlinkSync(target);
@@ -66,6 +95,12 @@ export function deleteEntry(dir, name) {
66
95
  return { error: `지우지 못했어요: ${err.message}` };
67
96
  }
68
97
  }
98
+ // child 가 parent 와 같거나 그 아래에 있는가
99
+ function isInside(child, parent) {
100
+ const a = path.resolve(child);
101
+ const b = path.resolve(parent);
102
+ return a === b || a.startsWith(b.endsWith(path.sep) ? b : b + path.sep);
103
+ }
69
104
  export function listDirs(dir, opts = {}) {
70
105
  const target = path.resolve(dir);
71
106
  const up = path.dirname(target);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellbase",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {