shellbase 0.13.6 → 0.14.0

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/README.md CHANGED
@@ -158,11 +158,28 @@ Claude Code 가 화면을 꽉 채우고 있을 때 `git status` 같은 걸 잠
158
158
  - 모나코에서 **JS/TS 타입 기반 자동완성은 빠져 있어요** (그 기능 파일 하나가 6.6MB 라 웹 스토리지 한도를
159
159
  넘습니다). 색칠·단축키·검색은 모두 정상이에요.
160
160
 
161
- - 512KB 까지, **글자 파일만** 열려요 (사진·실행파일 같은 이진 파일은 자동으로 걸러집니다)
161
+ - 512KB 까지, **글자 파일만** 열려요 (사진·동영상은 아래의 보기 창으로 열립니다)
162
162
  - 저장은 같은 폴더의 임시 파일에 먼저 쓴 뒤 교체해서, 저장 도중 끊겨도 원본이 깨지지 않아요
163
163
  - 파일 내용은 터미널 화면과 똑같이 세션 키로 암호화해서 주고받아요
164
164
  - 에디터는 **파일을 처음 열 때만 따로 내려받아요** — 터미널만 쓰면 그만큼 안 받습니다
165
165
 
166
+ ### 사진·동영상 보기 (0.14.0+)
167
+
168
+ 폴더 찾기에서 **사진(🖼)·동영상(🎬)·소리(🎵)·PDF(📕)** 를 탭하면 편집기 대신 **보기 창**이 열려서
169
+ 폰에서 그대로 보입니다. 동영상은 재생·되감기까지 돼요.
170
+
171
+ - 열리는 형식: `png jpg gif webp avif bmp heic tif` · `mp4 mov m4v webm mkv avi` ·
172
+ `mp3 m4a aac wav ogg opus flac` · `pdf`
173
+ - **64MB 까지**, 그리고 **3MB 가 넘으면 "받을까요?" 를 한 번 물어봐요** — 파일이 터미널 화면과
174
+ **같은 통로**로 오기 때문에, 큰 동영상을 잘못 누르면 그동안 터미널이 굼떠집니다
175
+ - 받는 동안 진행 막대가 보이고 **그만두기**를 누르면 곧바로 멈춰요 (컴퓨터 쪽도 바로 손을 뗍니다)
176
+ - 속도는 **초당 300KB 남짓**이에요 (실측) — 3MB 사진이면 10초쯤, 10MB 동영상이면 35초쯤
177
+ - 사진은 눌러서 **원래 크기 ↔ 화면 맞추기**를 오갈 수 있어요
178
+ - 파일은 32KB 조각으로 나눠 **세션 키로 암호화**해서 오고, 폰에서만 다시 하나로 붙습니다 —
179
+ 중간에 저장되는 곳은 없어요
180
+ - 브라우저가 못 여는 형식(아이폰 밖에서의 HEIC, mkv·avi 등)은 그렇게 알려주고, **경로를
181
+ 터미널에 넣어주는 버튼**을 보여줘요 — 그 자리에서 `ffmpeg` 로 바꾸면 됩니다
182
+
166
183
  ### 폰에서 사진·스크린샷 첨부 (0.7.0+)
167
184
 
168
185
  폰 화면의 **클립(📎) 버튼**(PC 는 상단 바)으로 사진을 고르면, 사진이 **이 컴퓨터의
@@ -173,7 +190,7 @@ Claude Code 가 화면을 꽉 채우고 있을 때 `git status` 같은 걸 잠
173
190
  - **PC 는 스크린샷 파일을 터미널 위로 끌어다 놓으면(드래그&드롭)** 바로 첨부돼요 (📎 버튼은 폰 전용)
174
191
  - 스크린샷을 **복사해서 붙여넣기**(Ctrl+V) 해도 첨부돼요 (PC·폰 공통)
175
192
  - 사진은 보내기 전에 폰에서 **긴 변 1568px 로 줄여서** 올라가요(전송이 빠르고 인식 정확도는 그대로)
176
- - 저장된 사진은 **7일이 지나면 자동으로 지워져요**, 파일 권한은 본인만 읽기(600)
193
+ - 저장된 사진은 **하루가 지나면 자동으로 지워져요**, 파일 권한은 본인만 읽기(600)
177
194
  - 사진도 터미널 화면과 똑같이 **세션 키로 암호화**해서 전송돼요
178
195
 
179
196
  ### 폰에서 마이크로 말하기 (음성 → 글자)
@@ -207,6 +224,23 @@ Claude Code 가 화면을 꽉 채우고 있을 때 `git status` 같은 걸 잠
207
224
  > 끄는 건 프로세스가 계속 살아있으니 그대로 꺼져요. 컨테이너를 완전히 내리려면 컴퓨터에서
208
225
  > `docker stop shellbase-agent` 를 쓰세요.
209
226
 
227
+ ### 터미널은 셸 시작 파일(rc)을 읽지 않아요
228
+
229
+ 폰에서 여는 터미널은 `~/.bashrc`(zsh 는 `~/.zshrc`) 같은 **시작 파일을 읽지 않고** 맨 셸로 뜹니다
230
+ (`bash --norc --noprofile`, `zsh --no-rcs`, `fish --no-config`, PowerShell `-NoProfile`).
231
+
232
+ 시작 파일은 그 컴퓨터 앞에 앉은 사람이 자기 편하려고 꾸며둔 것이라, 그 컴퓨터에만 있는 명령을
233
+ 가리키는 별칭이 흔합니다. 그게 딸려오면 다른 환경에서 명령이 엉뚱하게 깨져요
234
+ (`bash: gnuls: command not found` 같은 증상).
235
+
236
+ - 대신 **에이전트를 켤 때의 환경변수(PATH 포함)를 그대로 물려받습니다.** 그래서 `shellbase` 를
237
+ 실행할 수 있던 셸에서 켰다면 `claude` 같은 명령은 그대로 잘 찾습니다.
238
+ - 시작 파일에서만 붙던 것(conda 초기화, 개인 별칭, 특정 도구 경로)은 안 따라옵니다.
239
+ 필요하면 그 줄을 **에이전트를 켜는 쪽**에 넣거나, 세션 안에서 그때그때 불러 쓰세요
240
+ (예: `source ~/miniconda3/etc/profile.d/conda.sh`).
241
+ - 예전처럼 시작 파일을 읽게 하려면 `SHELLBASE_SHELL_RC=1 shellbase start …` 로 켜세요.
242
+ - 프롬프트는 꾸밈 없는 기본값(`bash-5.2$`)이 됩니다.
243
+
210
244
  ## 참고
211
245
 
212
246
  - `node-pty` 를 사용해서 설치 시 네이티브 모듈을 컴파일합니다. macOS는 Xcode Command Line Tools,
package/dist/agent.js CHANGED
@@ -17,6 +17,7 @@ import { promptApproval } from './prompt.js';
17
17
  import { touchRecentDir } from './recent-dirs.js';
18
18
  import { listDirs, createEntry, deleteEntry, renameEntry } from './browse.js';
19
19
  import { readTextFile, writeTextFile, chunkContent } from './files.js';
20
+ import { MediaReader, statMedia } from './media.js';
20
21
  import { AUDIO_CHUNK_TTL_MS, MAX_AUDIO_BASE64, STT_SETUP_HINT, findSttServer, transcribe, } from './stt.js';
21
22
  import { loadOpenSessions, saveOpenSessions } from './open-sessions.js';
22
23
  import { pushSessionDone } from './push.js';
@@ -61,6 +62,18 @@ const MAX_PACK_DEPTH = 12;
61
62
  // 전송이 막혀 밀린 출력이 이만큼을 넘으면, 밀린 것을 하나하나 따라 보내는 대신 지금 화면을 통째로
62
63
  // 다시 보낸다 — 사용자가 보게 될 최종 화면은 어차피 같고, 훨씬 빨리 따라잡는다.
63
64
  const MAX_PENDING_OUTPUT = 256 * 1024;
65
+ // 사진·동영상을 보낼 때 서버 답을 안 기다리고 잇달아 부치는 조각 수.
66
+ // 하나씩 답을 기다리면 조각마다 왕복 한 번(실측 276ms)이 그대로 지연이 되어 3MB 사진에 몇 분이
67
+ // 걸린다. 그렇다고 통째로 부치면 터미널 화면이 그 뒤로 밀린다 — 통로를 세션들이 나눠 쓰기 때문.
68
+ //
69
+ // **8이면 충분하다는 것은 실측으로 확인했다** (3MB 사진, 2026-08-21):
70
+ // 16·32 로 넓혀도 빨라지지 않았다 (229 → 173 → 183KB/s). 병목이 "답을 기다리는 시간" 이 아니라
71
+ // **통로의 굵기**라는 뜻이다. 그러니 더 넓혀봐야 터미널만 뒤로 밀린다.
72
+ // 올리기만 하는 속도는 394KB/s — 폰은 받는 길이 따로라 실제로는 이쪽에 가깝다.
73
+ const MEDIA_WINDOW = 8;
74
+ // 조각 하나가 실패했을 때 다시 부쳐보는 횟수. 이진 파일은 한 조각만 빠져도 아예 안 열리므로
75
+ // 글자 파일보다 끈질기게 다시 보낸다.
76
+ const MEDIA_RETRIES = 3;
64
77
  // 출력 전송 최소 간격. "마지막 전송 후 이 간격이 지났으면 바로 보내고, 아니면 그만큼만 기다렸다 모아서
65
78
  // 보낸다"(leading-edge 스로틀). 예전엔 문서의 "최소 100ms" 를 보수적으로 잡아 120ms 로 뒀는데, 실측해보니
66
79
  // 서버가 40회 연속 전송도 거부 없이 받아줬고 이 값이 타이핑 체감 지연의 주범이었음(입력·출력 양쪽에서
@@ -173,6 +186,40 @@ function resolveShell() {
173
186
  return process.env.COMSPEC ?? 'powershell.exe';
174
187
  return process.env.SHELL ?? '/bin/bash';
175
188
  }
189
+ // 폰에서 여는 터미널은 셸의 '시작 파일'(bash 의 ~/.bashrc, zsh 의 ~/.zshrc 같은 것)을 읽지 않는다.
190
+ // 시작 파일은 그 컴퓨터의 사람이 앉아서 쓰려고 꾸며둔 것이라, 그 컴퓨터에만 있는 명령을 가리키는
191
+ // 별칭이 흔하다. 그게 그대로 딸려오면 엉뚱한 곳에서 명령이 깨진다 (`gnuls: command not found`
192
+ // 사건 — PLAN.md §8.12). 그래서 껍데기 셸만 띄우고, 실행에 필요한 경로·환경변수는 시작 파일이
193
+ // 아니라 **에이전트를 켤 때의 환경**을 그대로 물려주는 쪽으로 통일한다 (sessionEnv 참고).
194
+ // 예전처럼 시작 파일을 읽게 하려면 SHELLBASE_SHELL_RC=1 을 주고 에이전트를 다시 시작하면 된다.
195
+ function shellArgs(shell) {
196
+ if (process.env.SHELLBASE_SHELL_RC)
197
+ return [];
198
+ const name = path.basename(shell).toLowerCase();
199
+ // 로그인용 파일(~/.profile 등)은 애초에 로그인 셸일 때만 읽히지만, 혹시 몰라 같이 막아둔다
200
+ if (name.includes('bash'))
201
+ return ['--norc', '--noprofile'];
202
+ if (name.includes('zsh'))
203
+ return ['--no-rcs'];
204
+ if (name.includes('fish'))
205
+ return ['--no-config'];
206
+ if (name.includes('pwsh') || name.includes('powershell'))
207
+ return ['-NoProfile'];
208
+ // sh·dash·ksh 는 이런 옵션이 없고 $ENV / $BASH_ENV 에 적힌 파일을 읽는다 — 그건 sessionEnv 에서 지운다
209
+ return [];
210
+ }
211
+ // 이 터미널이 어느 세션인지 환경변수로 심어둔다. 이 안에서 실행되는 모든 것(Claude Code,
212
+ // 그리고 Claude Code 가 끝날 때 실행하는 `shellbase notify`)이 그대로 물려받으므로,
213
+ // 알림이 왔을 때 어느 세션인지 바로 알 수 있다 (notify.ts).
214
+ function sessionEnv(deviceId) {
215
+ const env = { ...process.env, [SESSION_ENV]: deviceId };
216
+ if (!process.env.SHELLBASE_SHELL_RC) {
217
+ // sh·dash·ksh 계열은 옵션 대신 이 변수에 적힌 파일을 시작하면서 읽는다 — 같이 비운다
218
+ delete env.ENV;
219
+ delete env.BASH_ENV;
220
+ }
221
+ return env;
222
+ }
176
223
  function readControl(data, key) {
177
224
  if (typeof data !== 'string')
178
225
  return null;
@@ -735,15 +782,12 @@ export async function runAgent(options) {
735
782
  name: explicitName ?? (temporary ? `${defaultName(cwd)} (임시)` : defaultName(cwd)),
736
783
  cwd,
737
784
  rowId: '',
738
- pty: pty.spawn(shell, [], {
785
+ pty: pty.spawn(shell, shellArgs(shell), {
739
786
  name: 'xterm-256color',
740
787
  cols: 80,
741
788
  rows: 24,
742
789
  cwd,
743
- // 이 터미널이 어느 세션인지 환경변수로 심어둔다. 이 안에서 실행되는 모든 것(Claude Code,
744
- // 그리고 Claude Code 가 끝날 때 실행하는 `shellbase notify`)이 그대로 물려받으므로,
745
- // 알림이 왔을 때 어느 세션인지 바로 알 수 있다 (notify.ts).
746
- env: { ...process.env, [SESSION_ENV]: deviceId },
790
+ env: sessionEnv(deviceId),
747
791
  }),
748
792
  screen: new HeadlessTerminal({
749
793
  cols: 80,
@@ -777,6 +821,7 @@ export async function runAgent(options) {
777
821
  viewerSeenAt: Date.now(),
778
822
  // 폰이 접속하면서 "압축 읽을 수 있다" 고 알려주기 전까지는 압축하지 않는다
779
823
  gzipOk: false,
824
+ mediaId: null,
780
825
  };
781
826
  session.screen.loadAddon(session.serializer);
782
827
  session.pty.onData((data) => {
@@ -1095,6 +1140,96 @@ export async function runAgent(options) {
1095
1140
  console.log(`📱 폰에서 파일을 저장했어요: ${filePath}`);
1096
1141
  return;
1097
1142
  }
1143
+ // 폰에서 사진·동영상·소리 파일 보기 — **두 걸음**으로 나눠서 준다.
1144
+ // ① { path } 만 오면 "무슨 파일이고 얼마나 큰지"(media_meta)만 알려준다
1145
+ // ② 폰이 { path, mid, start: true } 로 다시 물어야 그때 조각을 보낸다
1146
+ // 한 걸음으로 하면 동영상을 잘못 누른 순간 수십 MB 가 통로로 쏟아진다. 크기를 먼저 알려주면
1147
+ // 폰이 "40MB 예요, 받을까요?" 를 물어본 뒤에 시작할 수 있다.
1148
+ if (frame.kind === 'read_media') {
1149
+ const control = readControl(frame.data, session.frameKey);
1150
+ if (!control || typeof control.path !== 'string')
1151
+ return;
1152
+ const filePath = control.path;
1153
+ const mid = typeof control.mid === 'string' ? control.mid : '';
1154
+ const fail = (reason) => void send({
1155
+ kind: 'media_error',
1156
+ to: session.deviceId,
1157
+ data: encryptFrame(JSON.stringify({ path: filePath, mid, reason }), session.frameKey),
1158
+ });
1159
+ // 「그만두기」 — 보내던 것을 멈추기만 한다 (보내는 쪽이 조각마다 이 표를 확인한다)
1160
+ if (control.stop) {
1161
+ if (!mid || session.mediaId === mid)
1162
+ session.mediaId = null;
1163
+ return;
1164
+ }
1165
+ const info = statMedia(filePath);
1166
+ if (!info.ok) {
1167
+ fail(info.reason);
1168
+ return;
1169
+ }
1170
+ if (!control.start) {
1171
+ void send({
1172
+ kind: 'media_meta',
1173
+ to: session.deviceId,
1174
+ data: encryptFrame(JSON.stringify({
1175
+ path: filePath,
1176
+ mid,
1177
+ mime: info.mime,
1178
+ kind: info.kind,
1179
+ size: info.size,
1180
+ total: info.total,
1181
+ }), session.frameKey),
1182
+ });
1183
+ return;
1184
+ }
1185
+ // 새 받기가 시작되면 앞서 보내던 것은 스스로 멈춘다 (표가 바뀌었으므로)
1186
+ session.mediaId = mid;
1187
+ void (async () => {
1188
+ let reader;
1189
+ try {
1190
+ reader = new MediaReader(info);
1191
+ }
1192
+ catch (err) {
1193
+ fail(`파일을 열지 못했어요: ${err.message}`);
1194
+ return;
1195
+ }
1196
+ try {
1197
+ for (let from = 0; from < info.total; from += MEDIA_WINDOW) {
1198
+ if (session.mediaId !== mid || session.closing)
1199
+ return;
1200
+ const to = Math.min(from + MEDIA_WINDOW, info.total);
1201
+ let pending = [];
1202
+ for (let index = from; index < to; index++)
1203
+ pending.push(index);
1204
+ for (let attempt = 0; attempt < MEDIA_RETRIES && pending.length > 0; attempt++) {
1205
+ const results = await Promise.allSettled(pending.map((index) => sendFrame({
1206
+ kind: 'media_chunk',
1207
+ to: session.deviceId,
1208
+ mid,
1209
+ seq: index,
1210
+ total: info.total,
1211
+ data: encryptBytes(reader.chunk(index), session.frameKey),
1212
+ })));
1213
+ if (session.mediaId !== mid)
1214
+ return;
1215
+ pending = pending.filter((_, at) => results[at].status === 'rejected');
1216
+ }
1217
+ if (pending.length > 0) {
1218
+ fail('보내는 도중에 통로가 막혔어요. 잠시 뒤 다시 받아보세요.');
1219
+ return;
1220
+ }
1221
+ }
1222
+ const label = { image: '사진', video: '동영상', audio: '소리', pdf: 'PDF' }[info.kind];
1223
+ console.log(`📱 폰으로 ${label} 을 보냈어요: ${filePath}`);
1224
+ }
1225
+ finally {
1226
+ reader.close();
1227
+ if (session.mediaId === mid)
1228
+ session.mediaId = null;
1229
+ }
1230
+ })();
1231
+ return;
1232
+ }
1098
1233
  // 폰에서 마이크로 말한 녹음 — 조각이 다 모이면 이 컴퓨터의 음성인식 서버로 넘겨 글자로 돌려준다.
1099
1234
  // 녹음은 인터넷의 다른 음성인식 서비스로 나가지 않고 이 컴퓨터 안에서만 처리된다.
1100
1235
  if (frame.kind === 'stt_audio') {
package/dist/media.js ADDED
@@ -0,0 +1,139 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ // 폰에서 사진·동영상·소리 파일을 **보이게** 하는 부분.
5
+ // 글자 파일(files.ts)과 통로는 같지만 다루는 것이 다르다:
6
+ // · 글자는 조각을 이어 붙이면 그만이지만, 이진 파일은 한 바이트라도 틀리면 아예 안 열린다
7
+ // · 크기가 글자 파일과 자릿수가 다르다 (메모 몇 KB ↔ 동영상 수십 MB)
8
+ // 그래서 base64 를 한 겹 더 씌우지 않고 **바이트 그대로 암호화해서**(crypto.ts 의 encryptBytes)
9
+ // 보낸다 — 같은 프레임에 3분의 1을 더 실을 수 있다.
10
+ // 한 조각에 담는 원본 바이트 수. 암호화하면 base64 로 약 1.34배가 되므로
11
+ // 32KB → 약 43KB 로, 프레임 한도(56KB)에 여유를 두고 들어간다.
12
+ export const MEDIA_CHUNK_BYTES = 32 * 1024;
13
+ // 이보다 큰 파일은 아예 보내지 않는다. 실시간 통로는 터미널 화면과 **같이 쓰는 길**이라
14
+ // (세션 여럿이 채널 하나를 나눠 쓴다) 너무 큰 파일은 그동안 터미널을 굼뜨게 만든다.
15
+ export const MAX_MEDIA_SIZE = 64 * 1024 * 1024;
16
+ // 확장자로 어떤 종류인지 본다. 파일 안을 뜯어보지 않는 이유는, 폰 브라우저도 결국
17
+ // 여기서 정한 종류(mime)를 믿고 재생하기 때문이다 — 판단 기준을 한 곳에만 두는 편이 낫다.
18
+ const MIME_BY_EXT = {
19
+ // 사진
20
+ png: 'image/png',
21
+ jpg: 'image/jpeg',
22
+ jpeg: 'image/jpeg',
23
+ gif: 'image/gif',
24
+ webp: 'image/webp',
25
+ avif: 'image/avif',
26
+ bmp: 'image/bmp',
27
+ ico: 'image/x-icon',
28
+ heic: 'image/heic',
29
+ heif: 'image/heif',
30
+ tif: 'image/tiff',
31
+ tiff: 'image/tiff',
32
+ // 동영상
33
+ mp4: 'video/mp4',
34
+ m4v: 'video/mp4',
35
+ mov: 'video/quicktime',
36
+ webm: 'video/webm',
37
+ ogv: 'video/ogg',
38
+ mkv: 'video/x-matroska',
39
+ avi: 'video/x-msvideo',
40
+ // 소리
41
+ mp3: 'audio/mpeg',
42
+ m4a: 'audio/mp4',
43
+ aac: 'audio/aac',
44
+ wav: 'audio/wav',
45
+ ogg: 'audio/ogg',
46
+ oga: 'audio/ogg',
47
+ opus: 'audio/ogg',
48
+ flac: 'audio/flac',
49
+ // 문서 — 폰 브라우저가 그대로 띄워준다
50
+ pdf: 'application/pdf',
51
+ };
52
+ export function mediaMime(name) {
53
+ const ext = path.extname(name).slice(1).toLowerCase();
54
+ return MIME_BY_EXT[ext] ?? null;
55
+ }
56
+ export function mediaKind(name) {
57
+ const mime = mediaMime(name);
58
+ if (!mime)
59
+ return null;
60
+ if (mime.startsWith('image/'))
61
+ return 'image';
62
+ if (mime.startsWith('video/'))
63
+ return 'video';
64
+ if (mime.startsWith('audio/'))
65
+ return 'audio';
66
+ return 'pdf';
67
+ }
68
+ function expand(target) {
69
+ if (target.startsWith('~'))
70
+ return path.join(os.homedir(), target.slice(1));
71
+ return path.resolve(target);
72
+ }
73
+ // 보내기 전에 "이 파일이 뭔지, 몇 조각인지" 만 먼저 알아본다.
74
+ // 폰은 이 답을 보고 큰 파일이면 "받을까요?" 를 한 번 물어본 뒤에야 받기를 시작한다.
75
+ export function statMedia(target) {
76
+ const full = expand(target);
77
+ let stat;
78
+ try {
79
+ stat = fs.statSync(full);
80
+ }
81
+ catch {
82
+ return { ok: false, reason: `파일을 찾을 수 없어요: ${full}` };
83
+ }
84
+ if (stat.isDirectory())
85
+ return { ok: false, reason: '폴더는 열 수 없어요.' };
86
+ const mime = mediaMime(full);
87
+ const kind = mediaKind(full);
88
+ if (!mime || !kind)
89
+ return { ok: false, reason: '폰에서 보여줄 수 있는 형식이 아니에요.' };
90
+ if (stat.size === 0)
91
+ return { ok: false, reason: '파일이 비어 있어요.' };
92
+ if (stat.size > MAX_MEDIA_SIZE) {
93
+ return {
94
+ ok: false,
95
+ reason: `파일이 너무 커요 (${Math.round(stat.size / 1024 / 1024)}MB) — ` +
96
+ `${MAX_MEDIA_SIZE / 1024 / 1024}MB 까지만 보낼 수 있어요.`,
97
+ };
98
+ }
99
+ return {
100
+ ok: true,
101
+ full,
102
+ mime,
103
+ kind,
104
+ size: stat.size,
105
+ total: Math.ceil(stat.size / MEDIA_CHUNK_BYTES),
106
+ };
107
+ }
108
+ // 파일을 열어두고 조각을 하나씩 꺼내 쓰는 손잡이. 통째로 메모리에 올리지 않는 이유는,
109
+ // 64MB 짜리를 여러 세션에서 동시에 열면 에이전트가 그만큼을 계속 들고 있게 되기 때문이다.
110
+ export class MediaReader {
111
+ info;
112
+ fd;
113
+ constructor(info, fd) {
114
+ this.info = info;
115
+ this.fd = fd ?? fs.openSync(info.full, 'r');
116
+ }
117
+ chunk(index) {
118
+ const offset = index * MEDIA_CHUNK_BYTES;
119
+ const length = Math.min(MEDIA_CHUNK_BYTES, this.info.size - offset);
120
+ const buffer = Buffer.allocUnsafe(length);
121
+ let read = 0;
122
+ // 한 번에 다 안 읽히는 경우가 있어서(네트워크 드라이브 등) 다 찰 때까지 이어 읽는다
123
+ while (read < length) {
124
+ const got = fs.readSync(this.fd, buffer, read, length - read, offset + read);
125
+ if (got <= 0)
126
+ break;
127
+ read += got;
128
+ }
129
+ return read === length ? buffer : buffer.subarray(0, read);
130
+ }
131
+ close() {
132
+ try {
133
+ fs.closeSync(this.fd);
134
+ }
135
+ catch {
136
+ // 이미 닫혔으면 그만
137
+ }
138
+ }
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellbase",
3
- "version": "0.13.6",
3
+ "version": "0.14.0",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {