shellbase 0.7.2 → 0.8.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
@@ -167,7 +167,11 @@ shellbase start --no-restore # 복구하지 않고 이 폴더 세션 하나
167
167
  컴퓨터가 꺼지지 않는 이상 자동으로 살아있게 하고 싶다면(예: 항상 켜져 있는 리눅스 서버), 아래처럼
168
168
  OS의 자동 재시작 기능을 직접 설정하면 됩니다. **실제로 테스트해서 확인한 방법이에요:**
169
169
 
170
- ### Linux (systemd, 실제 검증됨)
170
+ ### Linux (systemd, 실제 검증됨) — **권장**
171
+
172
+ > 도커로 감싸는 것보다 이 방법을 권합니다. 컨테이너 안에서는 그 터미널이 호스트의 `docker`·`systemctl`·
173
+ > 다른 컨테이너·호스트 서비스에 손을 댈 수 없어서, 정작 컴퓨터를 관리하려고 폰에서 접속했을 때 막힙니다.
174
+ > systemd 로 띄우면 재부팅 자동 복구는 똑같이 되면서 터미널 권한은 온전합니다.
171
175
 
172
176
  ```bash
173
177
  mkdir -p ~/.config/systemd/user
@@ -211,7 +215,13 @@ systemctl --user enable --now shellbase.service
211
215
  작업 스케줄러(Task Scheduler)에서 "로그온 시 시작" + "실패 시 다시 시작" 옵션으로 등록하면 비슷하게
212
216
  됩니다. 아직 저희가 Windows에서 직접 검증은 못 했어요.
213
217
 
214
- ### Docker (Linux, 실제 검증됨)
218
+ ### Docker (Linux, 실제 검증됨) — 격리가 꼭 필요할 때만
219
+
220
+ > ⚠️ **컨테이너 안 터미널은 호스트 시스템에 손이 닿지 않습니다.** 파일 작업(프로젝트 편집·빌드·git)은
221
+ > 홈 폴더를 마운트하므로 100% 동일하지만, `docker`/`systemctl`/다른 컨테이너/호스트 서비스는 못 씁니다
222
+ > (도커의 정상적인 격리 동작). 컴퓨터 관리까지 폰에서 하려면 위의 systemd 방식을 쓰세요.
223
+ > `/var/run/docker.sock` 을 마운트하면 도커 제어는 가능해지지만, 그 순간 컨테이너가 호스트 전체를
224
+ > 조종할 수 있게 되어 격리의 의미가 사라집니다 — 권하지 않습니다.
215
225
 
216
226
  컨테이너 안은 이 컴퓨터의 실제 환경과 분리돼 있어서, 홈 폴더를 통째로 볼륨(`-v`)으로 연결해야
217
227
  git 설정·SSH 키·진짜 작업 파일들이 컨테이너 밖과 똑같이 보여요. 아래는 실제로 빌드해서 재부팅 시나리오
package/dist/agent.js CHANGED
@@ -3,6 +3,11 @@ import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import crypto from 'node:crypto';
5
5
  import * as pty from 'node-pty';
6
+ import { createRequire } from 'node:module';
7
+ import { SerializeAddon } from '@xterm/addon-serialize';
8
+ // @xterm/headless 는 CommonJS 라 ESM 이름 가져오기가 안 돼서(default 만 노출) require 로 가져온다
9
+ const requireCjs = createRequire(import.meta.url);
10
+ const { Terminal: HeadlessTerminal } = requireCjs('@xterm/headless');
6
11
  import { requireClient } from './auth.js';
7
12
  import { TERMINAL_CATEGORY, CONTROL_TOKEN } from './config.js';
8
13
  import { startSleepGuard, stopSleepGuard } from './sleep-guard.js';
@@ -14,10 +19,15 @@ import { listDirs } from './browse.js';
14
19
  import { readTextFile, writeTextFile, chunkContent } from './files.js';
15
20
  import { AUDIO_CHUNK_TTL_MS, MAX_AUDIO_BASE64, STT_SETUP_HINT, findSttServer, transcribe, } from './stt.js';
16
21
  import { loadOpenSessions, saveOpenSessions } from './open-sessions.js';
17
- import { IMAGE_CHUNK_TTL_MS, saveImage } from './images.js';
22
+ import { IMAGE_CHUNK_TTL_MS, pruneImages, saveImage } from './images.js';
18
23
  const HEARTBEAT_MS = 20_000;
24
+ // 올린 사진 정리 주기 — 보관 기한이 하루라, 오래 켜둔 컴퓨터에서도 반나절 안에 치워진다
25
+ const IMAGE_SWEEP_MS = 6 * 60 * 60 * 1000;
19
26
  const APPROVAL_TIMEOUT_MS = 30_000;
20
- const SCROLLBACK_MAX = 16_000;
27
+ // 복원해서 보낼 때 함께 보낼 이전 줄 수 (화면 위로 올려볼 수 있는 분량)
28
+ const RESTORE_SCROLLBACK_LINES = 300;
29
+ // 프레임 하나에 들어갈 수 있는 크기가 제한돼 있어서(실측 16KB 성공/64KB 실패) 나눠 보낸다
30
+ const RESTORE_CHUNK = 12 * 1024;
21
31
  // 출력 전송 최소 간격. "마지막 전송 후 이 간격이 지났으면 바로 보내고, 아니면 그만큼만 기다렸다 모아서
22
32
  // 보낸다"(leading-edge 스로틀). 예전엔 문서의 "최소 100ms" 를 보수적으로 잡아 120ms 로 뒀는데, 실측해보니
23
33
  // 서버가 40회 연속 전송도 거부 없이 받아줬고 이 값이 타이핑 체감 지연의 주범이었음(입력·출력 양쪽에서
@@ -179,7 +189,13 @@ export async function runAgent(options) {
179
189
  cwd,
180
190
  env: process.env,
181
191
  }),
182
- scrollback: '',
192
+ screen: new HeadlessTerminal({
193
+ cols: 80,
194
+ rows: 24,
195
+ scrollback: 2000,
196
+ allowProposedApi: true,
197
+ }),
198
+ serializer: new SerializeAddon(),
183
199
  inputApproved: false,
184
200
  approvalInFlight: false,
185
201
  outBuffer: '',
@@ -188,10 +204,11 @@ export async function runAgent(options) {
188
204
  pendingFlush: null,
189
205
  closing: false,
190
206
  };
207
+ session.screen.loadAddon(session.serializer);
191
208
  session.pty.onData((data) => {
192
209
  session.outBuffer += data;
193
- // 최근 출력을 조금 들고 있다가, 모바일이 (재)접속했을 때 놓친 화면 대신 다시 보여줌
194
- session.scrollback = (session.scrollback + data).slice(-SCROLLBACK_MAX);
210
+ // 화면 상태를 그대로 따라 그려둔다 (재접속 복원용)
211
+ session.screen.write(data);
195
212
  scheduleFlush(session);
196
213
  });
197
214
  session.pty.onExit(() => {
@@ -242,6 +259,7 @@ export async function runAgent(options) {
242
259
  catch {
243
260
  // 이미 죽었으면 무시
244
261
  }
262
+ session.screen.dispose();
245
263
  if (opts.deliberate)
246
264
  persistOpenSessions();
247
265
  console.log(`"${session.name}" 세션을 닫았어요. (남은 세션 ${sessions.size}개)`);
@@ -258,6 +276,7 @@ export async function runAgent(options) {
258
276
  return;
259
277
  shuttingDown = true;
260
278
  clearInterval(heartbeat);
279
+ clearInterval(imageSweep);
261
280
  for (const session of [...sessions.values()]) {
262
281
  session.closing = true;
263
282
  if (session.pendingFlush !== null)
@@ -303,14 +322,26 @@ export async function runAgent(options) {
303
322
  if (!session || session.closing)
304
323
  return;
305
324
  if (frame.kind === 'connect_request') {
306
- // 화면 보기는 승인 여부와 무관하게 항상 되므로, 승인을 묻기 전에 최근 화면부터 먼저 채워줌
307
- if (session.scrollback) {
308
- void send({
309
- kind: 'output',
310
- to: session.deviceId,
311
- data: encryptFrame(session.scrollback, session.frameKey),
312
- });
313
- }
325
+ // 화면 보기는 승인 여부와 무관하게 항상 되므로, 승인을 묻기 전에 지금 화면부터 그대로 복원해준다.
326
+ // 화면을 먼저 지우고(2J·3J) 복원 내용을 보내야, 폰에 남아있던 예전 화면과 섞이지 않는다.
327
+ void (async () => {
328
+ let snapshot;
329
+ try {
330
+ snapshot = session.serializer.serialize({ scrollback: RESTORE_SCROLLBACK_LINES });
331
+ }
332
+ catch (err) {
333
+ console.error('화면 복원 준비 실패:', err.message);
334
+ return;
335
+ }
336
+ const payload = `\x1b[H\x1b[2J\x1b[3J${snapshot}`;
337
+ for (let i = 0; i < payload.length; i += RESTORE_CHUNK) {
338
+ await send({
339
+ kind: 'output',
340
+ to: session.deviceId,
341
+ data: encryptFrame(payload.slice(i, i + RESTORE_CHUNK), session.frameKey),
342
+ });
343
+ }
344
+ })();
314
345
  // 승인을 묻지 않는 기본 설정에서는 요청이 올 때마다 곧바로 답한다.
315
346
  // (예전엔 "승인 진행 중"이면 건너뛰었는데, 그 사이 온 요청은 영영 답을 못 받아서
316
347
  // 폰이 "승인 대기 중" 화면에 갇히는 일이 생겼음 — 자동 승인은 기다릴 게 없으므로 항상 응답)
@@ -576,6 +607,13 @@ export async function runAgent(options) {
576
607
  }
577
608
  if (frame.kind === 'resize' && frame.cols && frame.rows) {
578
609
  session.pty.resize(frame.cols, frame.rows);
610
+ // 복원용 화면도 같은 크기로 맞춰야 다음 접속 때 줄바꿈이 어긋나지 않는다
611
+ try {
612
+ session.screen.resize(frame.cols, frame.rows);
613
+ }
614
+ catch {
615
+ // 크기 값이 이상하면 무시 (PTY 는 이미 반영됨)
616
+ }
579
617
  }
580
618
  });
581
619
  const heartbeat = setInterval(() => {
@@ -592,6 +630,16 @@ export async function runAgent(options) {
592
630
  ? `🎤 음성인식 서버를 찾았어요: ${url} — 폰에서 마이크로 말할 수 있어요.`
593
631
  : '🎤 음성인식 서버가 없어요 — 폰 마이크 기능은 꺼진 상태예요 (SHELLBASE_STT_URL 로 주소를 지정할 수 있어요).');
594
632
  });
633
+ // 올린 사진은 하루가 지나면 치운다. 예전엔 "새 사진을 저장할 때"만 청소해서, 사진을 한동안 안 올리면
634
+ // 기한이 지난 파일이 계속 남아 있었다 — 시작할 때 한 번, 그리고 켜져 있는 동안 주기적으로도 돌린다.
635
+ const removedAtStart = pruneImages();
636
+ if (removedAtStart > 0)
637
+ console.log(`🧹 하루 지난 사진 ${removedAtStart}장을 정리했어요.`);
638
+ const imageSweep = setInterval(() => {
639
+ const removed = pruneImages();
640
+ if (removed > 0)
641
+ console.log(`🧹 하루 지난 사진 ${removed}장을 정리했어요.`);
642
+ }, IMAGE_SWEEP_MS);
595
643
  startSleepGuard();
596
644
  process.on('SIGINT', () => void shutdownProcess());
597
645
  process.on('SIGTERM', () => void shutdownProcess());
package/dist/images.js CHANGED
@@ -9,8 +9,9 @@ export const IMAGE_DIR = join(homedir(), '.shellbase', 'images');
9
9
  export const IMAGE_CHUNK_TTL_MS = 180_000;
10
10
  // base64 글자 수 상한 (약 6MB 원본). 폰에서 이미 줄여서 보내지만 안전장치로 둔다.
11
11
  export const MAX_IMAGE_BASE64 = 8 * 1024 * 1024;
12
- // 올린 지 오래된 사진은 저장할 때마다 조용히 치운다 (디스크가 계속 차지 않게)
13
- const KEEP_MS = 7 * 24 * 60 * 60 * 1000;
12
+ // 올린 지 오래된 사진은 조용히 치운다 (디스크가 계속 차지 않게).
13
+ // 사진은 "경로를 터미널에 넣어 바로 쓰는" 임시 파일이라 하루면 충분 — 오래 두면 쌓이기만 한다.
14
+ export const IMAGE_KEEP_MS = 24 * 60 * 60 * 1000;
14
15
  const EXT_BY_TYPE = {
15
16
  'image/png': 'png',
16
17
  'image/jpeg': 'jpg',
@@ -18,14 +19,20 @@ const EXT_BY_TYPE = {
18
19
  'image/webp': 'webp',
19
20
  'image/gif': 'gif',
20
21
  };
21
- function pruneOld() {
22
+ // 오래된 사진 치우기 — 지운 개수를 돌려준다(시작할 때 안내를 찍으려고).
23
+ // 저장할 때뿐 아니라 `shellbase start` 와 하루 주기 타이머에서도 부른다: 예전엔 저장할 때만 돌아서
24
+ // 사진을 한동안 안 올리면 기한이 지난 파일이 계속 남아 있었다.
25
+ export function pruneImages() {
26
+ let removed = 0;
22
27
  try {
23
28
  const now = Date.now();
24
29
  for (const name of readdirSync(IMAGE_DIR)) {
25
30
  const full = join(IMAGE_DIR, name);
26
31
  try {
27
- if (now - statSync(full).mtimeMs > KEEP_MS)
32
+ if (now - statSync(full).mtimeMs > IMAGE_KEEP_MS) {
28
33
  unlinkSync(full);
34
+ removed += 1;
35
+ }
29
36
  }
30
37
  catch {
31
38
  // 지우다 실패해도 사진 저장 자체를 막지는 않는다
@@ -35,6 +42,7 @@ function pruneOld() {
35
42
  catch {
36
43
  // 폴더가 아직 없으면 지울 것도 없다
37
44
  }
45
+ return removed;
38
46
  }
39
47
  // 두 자리로 맞춘 시간 문자열 (파일 이름이 시간순으로 정렬되게)
40
48
  function stamp(date) {
@@ -60,7 +68,7 @@ export function saveImage(base64, mime) {
60
68
  return { ok: false, reason: '사진이 비어 있어요. 다시 골라주세요.' };
61
69
  try {
62
70
  mkdirSync(IMAGE_DIR, { recursive: true });
63
- pruneOld();
71
+ pruneImages();
64
72
  // 같은 초에 여러 장을 올려도 안 겹치도록 뒤에 짧은 임의 글자를 붙인다
65
73
  const suffix = Math.random().toString(36).slice(2, 6);
66
74
  const path = join(IMAGE_DIR, `${stamp(new Date())}-${suffix}.${ext}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellbase",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,8 @@
27
27
  "start": "node dist/cli.js"
28
28
  },
29
29
  "dependencies": {
30
+ "@xterm/addon-serialize": "^0.14.0",
31
+ "@xterm/headless": "^6.0.0",
30
32
  "commander": "^15.0.0",
31
33
  "connectbase-client": "^5.13.0",
32
34
  "node-pty": "^1.1.0"