shellbase 0.4.0 → 0.6.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
@@ -87,6 +87,8 @@ shellbase start --no-restore # 복구하지 않고 이 폴더 세션 하나
87
87
  | 세션 갈아타기 | 터미널 화면 위쪽의 **기기 이름(▾)** 탭 → 원하는 세션 선택 |
88
88
  | 세션 끄기 | 목록의 **전원 아이콘**, 또는 터미널 화면의 기기 이름(▾) → **이 세션 끄기** |
89
89
  | 화면만 닫기 | `← 목록` 으로 나가기 — 컴퓨터의 세션은 계속 살아있어요 |
90
+ | 파일 열어보기·고치기 | **`📁 폴더 찾기`** → 목록에서 **파일**을 탭하면 에디터가 열려요 (수정 후 `저장`) |
91
+ | 세션 이름 바꾸기 | 기기 이름(▾) → 세션 옆 연필 버튼 |
90
92
 
91
93
  - **끄기는 진짜로 꺼요.** 컴퓨터에서 돌아가던 그 터미널이 종료되고 목록에서도 자동으로 사라져요
92
94
  (`Ctrl+C` 를 누른 것과 같아요). 프로세스가 죽었는데 목록에만 남아있는 항목은 휴지통 아이콘으로
@@ -97,6 +99,16 @@ shellbase start --no-restore # 복구하지 않고 이 폴더 세션 하나
97
99
  종료돼요(그 대신 다음 실행 때 자동 복구됩니다). 세션을 하나만 끄고 싶으면 폰에서 그 세션의 `✕` 를
98
100
  쓰세요 — 나머지 세션은 그대로 유지돼요.
99
101
 
102
+ ### 파일 보기·편집
103
+
104
+ `📁 폴더 찾기` 목록에는 폴더와 함께 **파일**도 나와요. 파일을 탭하면 문법 강조가 되는 에디터가 열리고,
105
+ 고친 뒤 `저장` 을 누르면 그 컴퓨터의 파일이 실제로 바뀝니다.
106
+
107
+ - 512KB 까지, **글자 파일만** 열려요 (사진·실행파일 같은 이진 파일은 자동으로 걸러집니다)
108
+ - 저장은 같은 폴더의 임시 파일에 먼저 쓴 뒤 교체해서, 저장 도중 끊겨도 원본이 깨지지 않아요
109
+ - 파일 내용은 터미널 화면과 똑같이 세션 키로 암호화해서 주고받아요
110
+ - 에디터는 **파일을 처음 열 때만 따로 내려받아요** — 터미널만 쓰면 그만큼 안 받습니다
111
+
100
112
  > ⚠️ **도커로 돌릴 때, 마지막 세션을 폰에서 끄면 도커가 다시 띄워요.** 세션이 0개가 되면 프로세스가
101
113
  > 종료되는데, `--restart unless-stopped` 는 정상 종료(exit 0)에도 컨테이너를 자동 재시작하기 때문이에요
102
114
  > (실제로 확인한 동작). 즉 마지막 세션 끄기는 **사실상 재시작**이 됩니다. 세션이 여러 개일 때 하나만
package/dist/agent.js CHANGED
@@ -11,6 +11,8 @@ import { registerDevice, heartbeatDevice, unregisterDevice, listOnlineDirs, rena
11
11
  import { promptApproval } from './prompt.js';
12
12
  import { touchRecentDir } from './recent-dirs.js';
13
13
  import { listDirs } from './browse.js';
14
+ import { readTextFile, writeTextFile, chunkContent } from './files.js';
15
+ import { AUDIO_CHUNK_TTL_MS, MAX_AUDIO_BASE64, STT_SETUP_HINT, findSttServer, transcribe, } from './stt.js';
14
16
  import { loadOpenSessions, saveOpenSessions } from './open-sessions.js';
15
17
  const HEARTBEAT_MS = 20_000;
16
18
  const APPROVAL_TIMEOUT_MS = 30_000;
@@ -25,6 +27,8 @@ const SEND_PENALTY_MS = 500;
25
27
  const PENALTY_INTERVAL_MS = 250;
26
28
  // 실수나 버그로 세션이 무한정 늘어나는 것을 막는 상한 (한 컴퓨터에서 이 이상 필요한 경우는 사실상 없음)
27
29
  const MAX_SESSIONS = 20;
30
+ // 저장 중인 파일 조각을 모아두는 시간 — 중간에 폰이 끊기면 조용히 버린다
31
+ const WRITE_BUFFER_TTL_MS = 60_000;
28
32
  // 폰의 폴더 탐색기를 열 때 "지금 셸이 있는 폴더"에서 시작하려면, 세션을 시작한 폴더가 아니라
29
33
  // 셸 프로세스의 실제 작업 폴더를 봐야 한다(사용자가 cd 로 옮겨 다니므로). 리눅스는 /proc 로 바로 알 수 있고,
30
34
  // 없는 OS(맥·윈도)에서는 세션 시작 폴더로 되돌아간다.
@@ -41,9 +45,6 @@ function resolveShell() {
41
45
  return process.env.COMSPEC ?? 'powershell.exe';
42
46
  return process.env.SHELL ?? '/bin/bash';
43
47
  }
44
- // 폰이 보내는 제어 프레임(끄기 / 새 세션 / 폴더 목록)은 그 세션의 키로 풀려야만 받아들인다.
45
- // 키는 devices 테이블의 내 row 에만 있고 RLS 로 주인만 읽을 수 있으니, 복호화에 성공했다는 것 자체가
46
- // "주인이 보낸 요청"이라는 증거가 된다 (입력 승인과는 별개 — 승인은 타이핑 권한용).
47
48
  function readControl(data, key) {
48
49
  if (typeof data !== 'string')
49
50
  return null;
@@ -75,6 +76,12 @@ export async function runAgent(options) {
75
76
  const hostLabel = options.name ?? os.hostname();
76
77
  const shell = resolveShell();
77
78
  const sessions = new Map();
79
+ // 폰에서 저장 중인 파일 조각 모음 (경로별)
80
+ const writeBuffers = new Map();
81
+ // 폰에서 온 녹음 조각 모음 (파일 저장과 같은 방식)
82
+ const audioBuffers = new Map();
83
+ // 이 컴퓨터에서 찾은 음성인식 서버 주소 — 시작할 때 한 번 찾고, 없으면 요청이 올 때 다시 찾는다
84
+ let sttUrl = null;
78
85
  function defaultName(cwd) {
79
86
  return cwd === os.homedir() ? hostLabel : `${hostLabel} · ${path.basename(cwd)}`;
80
87
  }
@@ -338,6 +345,135 @@ export async function runAgent(options) {
338
345
  });
339
346
  return;
340
347
  }
348
+ // 폰에서 파일 열기 — 실시간 프레임 크기 제한(20KB 남짓) 때문에 조각으로 나눠 보낸다
349
+ if (frame.kind === 'read_file') {
350
+ const control = readControl(frame.data, session.frameKey);
351
+ if (!control || typeof control.path !== 'string')
352
+ return;
353
+ const filePath = control.path;
354
+ const result = readTextFile(filePath);
355
+ if (!result.ok) {
356
+ void send({
357
+ kind: 'file_error',
358
+ to: session.deviceId,
359
+ data: encryptFrame(JSON.stringify({ path: filePath, reason: result.reason }), session.frameKey),
360
+ });
361
+ return;
362
+ }
363
+ const chunks = chunkContent(result.content);
364
+ void (async () => {
365
+ for (let index = 0; index < chunks.length; index++) {
366
+ await send({
367
+ kind: 'file_chunk',
368
+ to: session.deviceId,
369
+ data: encryptFrame(JSON.stringify({ path: filePath, index, total: chunks.length, chunk: chunks[index] }), session.frameKey),
370
+ });
371
+ }
372
+ })();
373
+ return;
374
+ }
375
+ // 폰에서 파일 저장 — 조각이 다 모이면 임시 파일에 쓴 뒤 교체한다 (files.ts)
376
+ if (frame.kind === 'write_file') {
377
+ const control = readControl(frame.data, session.frameKey);
378
+ if (!control ||
379
+ typeof control.path !== 'string' ||
380
+ typeof control.chunk !== 'string' ||
381
+ typeof control.index !== 'number' ||
382
+ typeof control.total !== 'number') {
383
+ return;
384
+ }
385
+ const key = `${session.deviceId}:${control.path}`;
386
+ const now = Date.now();
387
+ for (const [bufferKey, buffer] of writeBuffers) {
388
+ if (now - buffer.at > WRITE_BUFFER_TTL_MS)
389
+ writeBuffers.delete(bufferKey);
390
+ }
391
+ const buffer = control.index === 0
392
+ ? { parts: [], total: control.total, at: now }
393
+ : (writeBuffers.get(key) ?? { parts: [], total: control.total, at: now });
394
+ buffer.parts[control.index] = control.chunk;
395
+ buffer.at = now;
396
+ writeBuffers.set(key, buffer);
397
+ if (buffer.parts.filter((part) => typeof part === 'string').length < buffer.total)
398
+ return;
399
+ writeBuffers.delete(key);
400
+ const filePath = control.path;
401
+ const result = writeTextFile(filePath, buffer.parts.join(''));
402
+ void send(result.ok
403
+ ? {
404
+ kind: 'file_saved',
405
+ to: session.deviceId,
406
+ data: encryptFrame(JSON.stringify({ path: filePath }), session.frameKey),
407
+ }
408
+ : {
409
+ kind: 'file_error',
410
+ to: session.deviceId,
411
+ data: encryptFrame(JSON.stringify({ path: filePath, reason: result.reason }), session.frameKey),
412
+ });
413
+ if (result.ok)
414
+ console.log(`📱 폰에서 파일을 저장했어요: ${filePath}`);
415
+ return;
416
+ }
417
+ // 폰에서 마이크로 말한 녹음 — 조각이 다 모이면 이 컴퓨터의 음성인식 서버로 넘겨 글자로 돌려준다.
418
+ // 녹음은 인터넷의 다른 음성인식 서비스로 나가지 않고 이 컴퓨터 안에서만 처리된다.
419
+ if (frame.kind === 'stt_audio') {
420
+ const control = readControl(frame.data, session.frameKey);
421
+ if (!control ||
422
+ typeof control.id !== 'string' ||
423
+ typeof control.chunk !== 'string' ||
424
+ typeof control.index !== 'number' ||
425
+ typeof control.total !== 'number') {
426
+ return;
427
+ }
428
+ const recordId = control.id;
429
+ const key = `${session.deviceId}:${recordId}`;
430
+ const now = Date.now();
431
+ for (const [bufferKey, buffer] of audioBuffers) {
432
+ if (now - buffer.at > AUDIO_CHUNK_TTL_MS)
433
+ audioBuffers.delete(bufferKey);
434
+ }
435
+ const buffer = control.index === 0
436
+ ? { parts: [], total: control.total, at: now }
437
+ : (audioBuffers.get(key) ?? { parts: [], total: control.total, at: now });
438
+ buffer.parts[control.index] = control.chunk;
439
+ buffer.at = now;
440
+ audioBuffers.set(key, buffer);
441
+ if (buffer.parts.filter((part) => typeof part === 'string').length < buffer.total)
442
+ return;
443
+ audioBuffers.delete(key);
444
+ const audioBase64 = buffer.parts.join('');
445
+ const fail = (reason) => send({
446
+ kind: 'stt_error',
447
+ to: session.deviceId,
448
+ data: encryptFrame(JSON.stringify({ id: recordId, reason }), session.frameKey),
449
+ });
450
+ if (audioBase64.length > MAX_AUDIO_BASE64) {
451
+ void fail('녹음이 너무 길어요. 조금 짧게 나눠서 말해주세요.');
452
+ return;
453
+ }
454
+ void (async () => {
455
+ if (!sttUrl)
456
+ sttUrl = await findSttServer();
457
+ if (!sttUrl) {
458
+ await fail(STT_SETUP_HINT);
459
+ return;
460
+ }
461
+ const result = await transcribe(sttUrl, audioBase64);
462
+ if (!result.ok) {
463
+ // 서버가 꺼졌을 수도 있으니 다음 요청 때 다시 찾도록 주소를 비운다
464
+ sttUrl = null;
465
+ await fail(result.reason);
466
+ return;
467
+ }
468
+ console.log(`📱 폰에서 말한 내용을 받아썼어요: "${result.text}"`);
469
+ await send({
470
+ kind: 'stt_text',
471
+ to: session.deviceId,
472
+ data: encryptFrame(JSON.stringify({ id: recordId, text: result.text }), session.frameKey),
473
+ });
474
+ })();
475
+ return;
476
+ }
341
477
  if (frame.kind === 'list_dirs') {
342
478
  const control = readControl(frame.data, session.frameKey);
343
479
  if (!control)
@@ -394,6 +530,13 @@ export async function runAgent(options) {
394
530
  heartbeatDevice(cb, session.rowId).catch((err) => console.error(`하트비트 실패(${session.name}):`, err.message));
395
531
  }
396
532
  }, HEARTBEAT_MS);
533
+ // 음성인식 서버가 이 컴퓨터에 떠 있으면 폰에서 마이크 버튼을 쓸 수 있다 (없어도 나머지 기능은 그대로)
534
+ void findSttServer().then((url) => {
535
+ sttUrl = url;
536
+ console.log(url
537
+ ? `🎤 음성인식 서버를 찾았어요: ${url} — 폰에서 마이크로 말할 수 있어요.`
538
+ : '🎤 음성인식 서버가 없어요 — 폰 마이크 기능은 꺼진 상태예요 (SHELLBASE_STT_URL 로 주소를 지정할 수 있어요).');
539
+ });
397
540
  startSleepGuard();
398
541
  process.on('SIGINT', () => void shutdownProcess());
399
542
  process.on('SIGTERM', () => void shutdownProcess());
package/dist/browse.js CHANGED
@@ -12,6 +12,7 @@ export function listDirs(dir) {
12
12
  parent: up === target ? null : up,
13
13
  home: os.homedir(),
14
14
  entries: [],
15
+ files: [],
15
16
  };
16
17
  let items;
17
18
  try {
@@ -40,9 +41,22 @@ export function listDirs(dir) {
40
41
  })
41
42
  .map((entry) => entry.name)
42
43
  .sort((a, b) => a.localeCompare(b));
44
+ // 파일은 이름과 크기만 — 열어볼 수 있는지 폰에서 미리 판단할 수 있게 한다
45
+ const files = items
46
+ .filter((entry) => !entry.name.startsWith('.') && entry.isFile())
47
+ .map((entry) => {
48
+ try {
49
+ return { name: entry.name, size: fs.statSync(path.join(target, entry.name)).size };
50
+ }
51
+ catch {
52
+ return { name: entry.name, size: 0 };
53
+ }
54
+ })
55
+ .sort((a, b) => a.name.localeCompare(b.name));
43
56
  return {
44
57
  ...base,
45
58
  entries: names.slice(0, MAX_ENTRIES),
46
- truncated: names.length > MAX_ENTRIES,
59
+ files: files.slice(0, MAX_ENTRIES),
60
+ truncated: names.length > MAX_ENTRIES || files.length > MAX_ENTRIES,
47
61
  };
48
62
  }
package/dist/files.js ADDED
@@ -0,0 +1,69 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ // 폰에서 파일을 열어보고 고칠 수 있게 하는 읽기/쓰기. 실시간 프레임은 한 번에 20KB 남짓만 보낼 수 있어서
5
+ // (실측: 16KB 성공 / 64KB 거부) 파일은 조각으로 나눠 주고받는다.
6
+ export const CHUNK_SIZE = 12 * 1024;
7
+ export const MAX_FILE_SIZE = 512 * 1024;
8
+ function expand(target) {
9
+ if (target.startsWith('~'))
10
+ return path.join(os.homedir(), target.slice(1));
11
+ return path.resolve(target);
12
+ }
13
+ export function readTextFile(target) {
14
+ const full = expand(target);
15
+ let stat;
16
+ try {
17
+ stat = fs.statSync(full);
18
+ }
19
+ catch {
20
+ return { ok: false, reason: `파일을 찾을 수 없어요: ${full}` };
21
+ }
22
+ if (stat.isDirectory())
23
+ return { ok: false, reason: '폴더는 열 수 없어요.' };
24
+ if (stat.size > MAX_FILE_SIZE) {
25
+ return {
26
+ ok: false,
27
+ reason: `파일이 너무 커요 (${Math.round(stat.size / 1024)}KB) — ${MAX_FILE_SIZE / 1024}KB 까지만 열 수 있어요.`,
28
+ };
29
+ }
30
+ let buffer;
31
+ try {
32
+ buffer = fs.readFileSync(full);
33
+ }
34
+ catch (err) {
35
+ return { ok: false, reason: `파일을 읽지 못했어요: ${err.message}` };
36
+ }
37
+ // 사진·실행파일 같은 이진 파일은 에디터에서 의미가 없고 깨져 보이기만 하므로 미리 거른다
38
+ if (buffer.subarray(0, 8192).includes(0)) {
39
+ return { ok: false, reason: '글자 파일이 아니에요 (이진 파일은 열 수 없어요).' };
40
+ }
41
+ return { ok: true, content: buffer.toString('utf8'), size: stat.size };
42
+ }
43
+ export function writeTextFile(target, content) {
44
+ const full = expand(target);
45
+ try {
46
+ // 같은 폴더에 임시 파일로 먼저 쓰고 교체 — 저장 도중 끊겨도 원본이 깨지지 않는다
47
+ const tmp = path.join(path.dirname(full), `.${path.basename(full)}.shellbase-tmp`);
48
+ let mode;
49
+ try {
50
+ mode = fs.statSync(full).mode;
51
+ }
52
+ catch {
53
+ // 새 파일이면 기본 권한
54
+ }
55
+ fs.writeFileSync(tmp, content, mode === undefined ? undefined : { mode });
56
+ fs.renameSync(tmp, full);
57
+ return { ok: true };
58
+ }
59
+ catch (err) {
60
+ return { ok: false, reason: `저장하지 못했어요: ${err.message}` };
61
+ }
62
+ }
63
+ export function chunkContent(content) {
64
+ const chunks = [];
65
+ for (let i = 0; i < content.length; i += CHUNK_SIZE) {
66
+ chunks.push(content.slice(i, i + CHUNK_SIZE));
67
+ }
68
+ return chunks.length > 0 ? chunks : [''];
69
+ }
package/dist/stt.js ADDED
@@ -0,0 +1,65 @@
1
+ // 폰에서 보낸 녹음을 "이 컴퓨터에 떠 있는 음성인식 서버"로 넘겨 글자로 바꾼다.
2
+ // 녹음이 컴퓨터 밖으로 나가지 않는 게 핵심 — 폰 → (암호화된 실시간 채널) → 이 컴퓨터 → 로컬 whisper.
3
+ // 실시간 프레임은 한 번에 20KB 남짓만 보낼 수 있어서 녹음도 파일과 같은 방식으로 조각내 받는다
4
+ export const AUDIO_CHUNK_TTL_MS = 60_000;
5
+ // 대략 1~2분 분량 (base64 로 부풀린 크기 기준)
6
+ export const MAX_AUDIO_BASE64 = 2 * 1024 * 1024;
7
+ // 서버를 찾는 순서: 직접 지정한 주소 → 이 컴퓨터 → 도커 안에서 본 호스트
8
+ function candidates() {
9
+ const list = [];
10
+ const configured = process.env.SHELLBASE_STT_URL?.trim();
11
+ if (configured)
12
+ list.push(configured.replace(/\/$/, ''));
13
+ list.push('http://127.0.0.1:5005', 'http://172.17.0.1:5005', 'http://host.docker.internal:5005');
14
+ return list;
15
+ }
16
+ async function isAlive(baseUrl) {
17
+ try {
18
+ const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(1500) });
19
+ return res.ok;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ // 시작할 때 한 번 찾아두고, 못 찾으면 요청이 올 때 다시 한 번 찾아본다(그 사이에 켰을 수도 있으니)
26
+ export async function findSttServer() {
27
+ for (const url of candidates()) {
28
+ if (await isAlive(url))
29
+ return url;
30
+ }
31
+ return null;
32
+ }
33
+ export async function transcribe(baseUrl, audioBase64) {
34
+ const languages = (process.env.SHELLBASE_STT_LANGS ?? 'ko')
35
+ .split(',')
36
+ .map((lang) => lang.trim())
37
+ .filter(Boolean);
38
+ try {
39
+ const res = await fetch(`${baseUrl}/transcribe_json`, {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify({ audio_base64: audioBase64, languages }),
43
+ signal: AbortSignal.timeout(60_000),
44
+ });
45
+ if (!res.ok) {
46
+ return { ok: false, reason: `음성인식 서버가 오류를 돌려줬어요 (HTTP ${res.status}).` };
47
+ }
48
+ const body = (await res.json());
49
+ if (body.ok === false) {
50
+ return { ok: false, reason: body.error ?? '음성을 알아듣지 못했어요.' };
51
+ }
52
+ const text = (body.text ?? '').trim();
53
+ if (!text)
54
+ return { ok: false, reason: '말소리가 들리지 않았어요. 다시 한 번 말해주세요.' };
55
+ return { ok: true, text };
56
+ }
57
+ catch (err) {
58
+ const reason = err.name === 'TimeoutError'
59
+ ? '음성인식이 너무 오래 걸려서 멈췄어요.'
60
+ : `음성인식 서버에 연결하지 못했어요: ${err.message}`;
61
+ return { ok: false, reason };
62
+ }
63
+ }
64
+ export const STT_SETUP_HINT = '이 컴퓨터에서 음성인식 서버를 찾지 못했어요. whisper 같은 음성인식 서버를 켜고, 주소가 다르면 ' +
65
+ 'SHELLBASE_STT_URL=http://127.0.0.1:5005 처럼 지정한 뒤 에이전트를 다시 시작해주세요.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellbase",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {