shellbase 0.6.0 → 0.7.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/README.md +37 -2
- package/dist/agent.js +58 -3
- package/dist/cli.js +0 -0
- package/dist/images.js +76 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,14 +101,49 @@ shellbase start --no-restore # 복구하지 않고 이 폴더 세션 하나
|
|
|
101
101
|
|
|
102
102
|
### 파일 보기·편집
|
|
103
103
|
|
|
104
|
-
`📁 폴더 찾기` 목록에는 폴더와 함께 **파일**도 나와요. 파일을 탭하면
|
|
105
|
-
|
|
104
|
+
`📁 폴더 찾기` 목록에는 폴더와 함께 **파일**도 나와요. 파일을 탭하면 편집기가 열리고, 고친 뒤 `저장` 을
|
|
105
|
+
누르면 그 컴퓨터의 파일이 실제로 바뀝니다. **기기에 따라 편집기가 다르게 열려요:**
|
|
106
|
+
|
|
107
|
+
- **노트북·데스크톱(넓은 화면 + 마우스)**: VS Code 와 같은 **모나코** — 찾기·바꾸기(Ctrl+F), 커서 여러 개,
|
|
108
|
+
줄 접기, 미니맵, `Ctrl+S` 저장이 그대로 동작해요. 파일을 처음 열 때만 내려받습니다.
|
|
109
|
+
- **폰**: 가벼운 편집기(CodeMirror) — 터치 우선이라 좁은 화면에서 편하고, `Tab` 들여쓰기와 `Ctrl+S` 저장도 됩니다.
|
|
110
|
+
- 모나코에서 **JS/TS 타입 기반 자동완성은 빠져 있어요** (그 기능 파일 하나가 6.6MB 라 웹 스토리지 한도를
|
|
111
|
+
넘습니다). 색칠·단축키·검색은 모두 정상이에요.
|
|
106
112
|
|
|
107
113
|
- 512KB 까지, **글자 파일만** 열려요 (사진·실행파일 같은 이진 파일은 자동으로 걸러집니다)
|
|
108
114
|
- 저장은 같은 폴더의 임시 파일에 먼저 쓴 뒤 교체해서, 저장 도중 끊겨도 원본이 깨지지 않아요
|
|
109
115
|
- 파일 내용은 터미널 화면과 똑같이 세션 키로 암호화해서 주고받아요
|
|
110
116
|
- 에디터는 **파일을 처음 열 때만 따로 내려받아요** — 터미널만 쓰면 그만큼 안 받습니다
|
|
111
117
|
|
|
118
|
+
### 폰에서 사진·스크린샷 첨부 (0.7.0+)
|
|
119
|
+
|
|
120
|
+
폰 화면의 **클립(📎) 버튼**(PC 는 상단 바)으로 사진을 고르면, 사진이 **이 컴퓨터의
|
|
121
|
+
`~/.shellbase/images/`** 에 파일로 저장되고 그 **파일 경로가 터미널에 자동으로 입력**됩니다.
|
|
122
|
+
이어서 "이 화면 좀 봐줘" 처럼 쓰고 엔터를 누르면 Claude Code 가 그 사진을 읽어요.
|
|
123
|
+
|
|
124
|
+
- 폰에서는 📎 버튼을 누르면 「사진 찍기 / 사진 보관함」이 함께 떠요 — 방금 찍은 **스크린샷은 보관함**에 있습니다
|
|
125
|
+
- **PC 는 스크린샷 파일을 터미널 위로 끌어다 놓으면(드래그&드롭)** 바로 첨부돼요 (📎 버튼은 폰 전용)
|
|
126
|
+
- 스크린샷을 **복사해서 붙여넣기**(Ctrl+V) 해도 첨부돼요 (PC·폰 공통)
|
|
127
|
+
- 사진은 보내기 전에 폰에서 **긴 변 1568px 로 줄여서** 올라가요(전송이 빠르고 인식 정확도는 그대로)
|
|
128
|
+
- 저장된 사진은 **7일이 지나면 자동으로 지워져요**, 파일 권한은 본인만 읽기(600)
|
|
129
|
+
- 사진도 터미널 화면과 똑같이 **세션 키로 암호화**해서 전송돼요
|
|
130
|
+
|
|
131
|
+
### 폰에서 마이크로 말하기 (음성 → 글자)
|
|
132
|
+
|
|
133
|
+
폰 화면 오른쪽 아래 **마이크 버튼**을 누르고 말하면, 녹음이 **이 컴퓨터로** 전송돼서 컴퓨터에 떠 있는
|
|
134
|
+
음성인식 서버가 글자로 바꿔줍니다. 기본값은 **말한 뒤 바로 보내기(엔터까지 자동)** 라서, 「완료」만
|
|
135
|
+
누르면 그대로 실행돼요 (Claude Code 에게 말로 시킬 때 편합니다). 확인하고 보내고 싶으면 명령창의
|
|
136
|
+
`말한 뒤 바로 보내기 끄기` 로 예전 방식(받아쓴 글자를 보여주고 `보내기 ↵` 를 누르는 방식)으로 바꿀 수 있어요.
|
|
137
|
+
|
|
138
|
+
- **녹음은 다른 회사 서버로 나가지 않아요** — 폰 → (암호화된 실시간 채널) → 내 컴퓨터 → 내 음성인식 서버
|
|
139
|
+
- 음성인식 서버는 시작할 때 자동으로 찾아요: `SHELLBASE_STT_URL` → `http://127.0.0.1:5005`
|
|
140
|
+
→ `http://172.17.0.1:5005`(도커 안에서 본 호스트) 순서
|
|
141
|
+
- 주소가 다르면 `SHELLBASE_STT_URL=http://127.0.0.1:9000` 처럼 지정하고 다시 시작하세요.
|
|
142
|
+
인식 언어는 `SHELLBASE_STT_LANGS=ko,en` 으로 바꿀 수 있어요 (기본 `ko`)
|
|
143
|
+
- 서버가 없으면 마이크 기능만 꺼지고 나머지는 그대로 동작해요 (시작할 때 로그로 알려줍니다)
|
|
144
|
+
- 필요한 서버 형식: `GET /health` 와 `POST /transcribe_json` (`{audio_base64, languages}` → `{ok, text}`) —
|
|
145
|
+
whisper 계열 서버면 대개 이 형태입니다
|
|
146
|
+
|
|
112
147
|
> ⚠️ **도커로 돌릴 때, 마지막 세션을 폰에서 끄면 도커가 다시 띄워요.** 세션이 0개가 되면 프로세스가
|
|
113
148
|
> 종료되는데, `--restart unless-stopped` 는 정상 종료(exit 0)에도 컨테이너를 자동 재시작하기 때문이에요
|
|
114
149
|
> (실제로 확인한 동작). 즉 마지막 세션 끄기는 **사실상 재시작**이 됩니다. 세션이 여러 개일 때 하나만
|
package/dist/agent.js
CHANGED
|
@@ -14,6 +14,7 @@ import { listDirs } from './browse.js';
|
|
|
14
14
|
import { readTextFile, writeTextFile, chunkContent } from './files.js';
|
|
15
15
|
import { AUDIO_CHUNK_TTL_MS, MAX_AUDIO_BASE64, STT_SETUP_HINT, findSttServer, transcribe, } from './stt.js';
|
|
16
16
|
import { loadOpenSessions, saveOpenSessions } from './open-sessions.js';
|
|
17
|
+
import { IMAGE_CHUNK_TTL_MS, saveImage } from './images.js';
|
|
17
18
|
const HEARTBEAT_MS = 20_000;
|
|
18
19
|
const APPROVAL_TIMEOUT_MS = 30_000;
|
|
19
20
|
const SCROLLBACK_MAX = 16_000;
|
|
@@ -80,6 +81,8 @@ export async function runAgent(options) {
|
|
|
80
81
|
const writeBuffers = new Map();
|
|
81
82
|
// 폰에서 온 녹음 조각 모음 (파일 저장과 같은 방식)
|
|
82
83
|
const audioBuffers = new Map();
|
|
84
|
+
// 폰에서 온 사진 조각 모음 (녹음과 같은 방식)
|
|
85
|
+
const imageBuffers = new Map();
|
|
83
86
|
// 이 컴퓨터에서 찾은 음성인식 서버 주소 — 시작할 때 한 번 찾고, 없으면 요청이 올 때 다시 찾는다
|
|
84
87
|
let sttUrl = null;
|
|
85
88
|
function defaultName(cwd) {
|
|
@@ -136,9 +139,7 @@ export async function runAgent(options) {
|
|
|
136
139
|
.then(async () => {
|
|
137
140
|
if (session.closing)
|
|
138
141
|
return;
|
|
139
|
-
const approved =
|
|
140
|
-
? true
|
|
141
|
-
: await promptApproval(`\n📱 "${session.name}" 세션에 모바일 접속 요청이 왔어요. 입력을 허용할까요? (y/N, 30초 내 미응답 시 자동 거부): `, APPROVAL_TIMEOUT_MS);
|
|
142
|
+
const approved = await promptApproval(`\n📱 "${session.name}" 세션에 모바일 접속 요청이 왔어요. 입력을 허용할까요? (y/N, 30초 내 미응답 시 자동 거부): `, APPROVAL_TIMEOUT_MS);
|
|
142
143
|
session.inputApproved = approved;
|
|
143
144
|
session.approvalInFlight = false;
|
|
144
145
|
await send({
|
|
@@ -310,6 +311,14 @@ export async function runAgent(options) {
|
|
|
310
311
|
data: encryptFrame(session.scrollback, session.frameKey),
|
|
311
312
|
});
|
|
312
313
|
}
|
|
314
|
+
// 승인을 묻지 않는 기본 설정에서는 요청이 올 때마다 곧바로 답한다.
|
|
315
|
+
// (예전엔 "승인 진행 중"이면 건너뛰었는데, 그 사이 온 요청은 영영 답을 못 받아서
|
|
316
|
+
// 폰이 "승인 대기 중" 화면에 갇히는 일이 생겼음 — 자동 승인은 기다릴 게 없으므로 항상 응답)
|
|
317
|
+
if (!requireApproval) {
|
|
318
|
+
session.inputApproved = true;
|
|
319
|
+
void send({ kind: 'connect_approved', to: session.deviceId });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
313
322
|
if (session.approvalInFlight)
|
|
314
323
|
return;
|
|
315
324
|
askApproval(session);
|
|
@@ -474,6 +483,52 @@ export async function runAgent(options) {
|
|
|
474
483
|
})();
|
|
475
484
|
return;
|
|
476
485
|
}
|
|
486
|
+
// 폰에서 올린 사진 — 조각이 다 모이면 ~/.shellbase/images 에 파일로 저장하고 경로를 돌려준다.
|
|
487
|
+
// 폰은 그 경로를 터미널에 넣어서 Claude Code 가 그림을 읽게 한다.
|
|
488
|
+
if (frame.kind === 'image_upload') {
|
|
489
|
+
const control = readControl(frame.data, session.frameKey);
|
|
490
|
+
if (!control ||
|
|
491
|
+
typeof control.id !== 'string' ||
|
|
492
|
+
typeof control.mime !== 'string' ||
|
|
493
|
+
typeof control.chunk !== 'string' ||
|
|
494
|
+
typeof control.index !== 'number' ||
|
|
495
|
+
typeof control.total !== 'number') {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const uploadId = control.id;
|
|
499
|
+
const mime = control.mime;
|
|
500
|
+
const key = `${session.deviceId}:${uploadId}`;
|
|
501
|
+
const now = Date.now();
|
|
502
|
+
for (const [bufferKey, buffer] of imageBuffers) {
|
|
503
|
+
if (now - buffer.at > IMAGE_CHUNK_TTL_MS)
|
|
504
|
+
imageBuffers.delete(bufferKey);
|
|
505
|
+
}
|
|
506
|
+
const buffer = control.index === 0
|
|
507
|
+
? { parts: [], total: control.total, at: now }
|
|
508
|
+
: (imageBuffers.get(key) ?? { parts: [], total: control.total, at: now });
|
|
509
|
+
buffer.parts[control.index] = control.chunk;
|
|
510
|
+
buffer.at = now;
|
|
511
|
+
imageBuffers.set(key, buffer);
|
|
512
|
+
if (buffer.parts.filter((part) => typeof part === 'string').length < buffer.total)
|
|
513
|
+
return;
|
|
514
|
+
imageBuffers.delete(key);
|
|
515
|
+
const result = saveImage(buffer.parts.join(''), mime);
|
|
516
|
+
if (!result.ok) {
|
|
517
|
+
void send({
|
|
518
|
+
kind: 'image_error',
|
|
519
|
+
to: session.deviceId,
|
|
520
|
+
data: encryptFrame(JSON.stringify({ id: uploadId, reason: result.reason }), session.frameKey),
|
|
521
|
+
});
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
console.log(`📱 폰에서 사진을 받았어요: ${result.path}`);
|
|
525
|
+
void send({
|
|
526
|
+
kind: 'image_saved',
|
|
527
|
+
to: session.deviceId,
|
|
528
|
+
data: encryptFrame(JSON.stringify({ id: uploadId, path: result.path }), session.frameKey),
|
|
529
|
+
});
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
477
532
|
if (frame.kind === 'list_dirs') {
|
|
478
533
|
const control = readControl(frame.data, session.frameKey);
|
|
479
534
|
if (!control)
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/images.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// 폰에서 찍거나 고른 사진을 이 컴퓨터에 파일로 내려놓는 부분.
|
|
2
|
+
// Claude Code 는 "파일 경로"만 프롬프트에 있으면 그림을 직접 읽을 수 있어서,
|
|
3
|
+
// 여기 저장한 뒤 경로를 폰으로 돌려주면 폰에서 그 경로를 터미널에 넣어 쓰게 된다.
|
|
4
|
+
import { mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
export const IMAGE_DIR = join(homedir(), '.shellbase', 'images');
|
|
8
|
+
// 조각(chunk)이 다 모이기를 기다리는 시간 — 사진은 녹음보다 조각이 많아서 넉넉하게
|
|
9
|
+
export const IMAGE_CHUNK_TTL_MS = 180_000;
|
|
10
|
+
// base64 글자 수 상한 (약 6MB 원본). 폰에서 이미 줄여서 보내지만 안전장치로 둔다.
|
|
11
|
+
export const MAX_IMAGE_BASE64 = 8 * 1024 * 1024;
|
|
12
|
+
// 올린 지 오래된 사진은 저장할 때마다 조용히 치운다 (디스크가 계속 차지 않게)
|
|
13
|
+
const KEEP_MS = 7 * 24 * 60 * 60 * 1000;
|
|
14
|
+
const EXT_BY_TYPE = {
|
|
15
|
+
'image/png': 'png',
|
|
16
|
+
'image/jpeg': 'jpg',
|
|
17
|
+
'image/jpg': 'jpg',
|
|
18
|
+
'image/webp': 'webp',
|
|
19
|
+
'image/gif': 'gif',
|
|
20
|
+
};
|
|
21
|
+
function pruneOld() {
|
|
22
|
+
try {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
for (const name of readdirSync(IMAGE_DIR)) {
|
|
25
|
+
const full = join(IMAGE_DIR, name);
|
|
26
|
+
try {
|
|
27
|
+
if (now - statSync(full).mtimeMs > KEEP_MS)
|
|
28
|
+
unlinkSync(full);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// 지우다 실패해도 사진 저장 자체를 막지는 않는다
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// 폴더가 아직 없으면 지울 것도 없다
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// 두 자리로 맞춘 시간 문자열 (파일 이름이 시간순으로 정렬되게)
|
|
40
|
+
function stamp(date) {
|
|
41
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
42
|
+
return (`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
|
|
43
|
+
`-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`);
|
|
44
|
+
}
|
|
45
|
+
export function saveImage(base64, mime) {
|
|
46
|
+
if (base64.length > MAX_IMAGE_BASE64) {
|
|
47
|
+
return { ok: false, reason: '사진이 너무 커요. 조금 작은 사진으로 다시 올려주세요.' };
|
|
48
|
+
}
|
|
49
|
+
const ext = EXT_BY_TYPE[mime.toLowerCase()];
|
|
50
|
+
if (!ext)
|
|
51
|
+
return { ok: false, reason: '이 형식의 사진은 아직 못 받아요 (png·jpg·webp·gif 만).' };
|
|
52
|
+
let bytes;
|
|
53
|
+
try {
|
|
54
|
+
bytes = Buffer.from(base64, 'base64');
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { ok: false, reason: '사진이 오는 도중에 깨졌어요. 다시 시도해주세요.' };
|
|
58
|
+
}
|
|
59
|
+
if (bytes.length === 0)
|
|
60
|
+
return { ok: false, reason: '사진이 비어 있어요. 다시 골라주세요.' };
|
|
61
|
+
try {
|
|
62
|
+
mkdirSync(IMAGE_DIR, { recursive: true });
|
|
63
|
+
pruneOld();
|
|
64
|
+
// 같은 초에 여러 장을 올려도 안 겹치도록 뒤에 짧은 임의 글자를 붙인다
|
|
65
|
+
const suffix = Math.random().toString(36).slice(2, 6);
|
|
66
|
+
const path = join(IMAGE_DIR, `${stamp(new Date())}-${suffix}.${ext}`);
|
|
67
|
+
writeFileSync(path, bytes, { mode: 0o600 });
|
|
68
|
+
return { ok: true, path };
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
reason: `사진을 저장하지 못했어요: ${err instanceof Error ? err.message : String(err)}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|