shellbase 0.8.0 → 0.9.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
@@ -36,7 +36,40 @@ shellbase start --dir ~/projects/my-app --name "내 노트북"
36
36
  | `shellbase login` | 구글 계정으로 로그인 |
37
37
  | `shellbase logout` | 로그아웃 |
38
38
  | `shellbase whoami` | 현재 로그인된 계정 확인 |
39
- | `shellbase start [--dir <path>] [--name <name>] [--require-approval] [--no-restore]` | 터미널 세션 시작 |
39
+ | `shellbase start [--dir <path>] [--name <name>] [--require-approval] [--no-restore] [--no-hook]` | 터미널 세션 시작 |
40
+ | `shellbase hook install` / `remove` / `status` | Claude Code 작업 완료 알림 켜기/끄기/확인 |
41
+ | `shellbase notify` | "작업이 끝났다"고 폰에 알림 (Claude Code 훅이 자동 실행 — 직접 칠 일은 없어요) |
42
+
43
+ ### 작업이 끝나면 폰으로 알려줘요
44
+
45
+ Claude Code 에게 긴 작업을 시켜놓고 폰을 내려놓아도, **끝나면 알 수 있어요.**
46
+
47
+ - 폰·PC 의 **세션 목록과 상단 칩에 주황 점(●)** 이 붙어요 — 그 세션에 들어가 보면 사라집니다
48
+ - 앱을 열어둔 상태라면 **알림창**도 띄울 수 있어요 (명령창 `Ctrl+Shift+P` → "작업 완료 알림 켜기")
49
+ - 앱을 아예 닫아둔 사이에 끝난 작업도 **●** 로 남아 있어서, 나중에 열어보면 바로 보여요
50
+
51
+ 끝난 걸 알아채는 방법은 두 가지고, 정확한 쪽이 우선입니다.
52
+
53
+ | 방법 | 정확도 | 준비 |
54
+ |---|---|---|
55
+ | **Claude Code 훅** | 정확 (Claude 가 대답을 마치는 바로 그 순간) | `shellbase hook install` 한 번 |
56
+ | 화면 출력 보고 짐작 | 어림짐작 | 없음 (훅이 없을 때 자동으로 대신 씀) |
57
+
58
+ `shellbase start` 를 처음 실행하면 훅을 걸지 한 번 물어봐요. `y` 를 누르면
59
+ `~/.claude/settings.json` 에 알림 한 줄이 들어갑니다 — **원래 있던 설정은 그대로 두고**, 고치기 전
60
+ 같은 자리에 `settings.json.shellbase-backup` 으로 백업도 남겨요. 이미 켜져 있던 Claude Code 는 다시
61
+ 시작해야 적용돼요.
62
+
63
+ ```bash
64
+ shellbase hook status # 지금 걸려 있는지 확인
65
+ shellbase hook install # 걸기
66
+ shellbase hook remove # 빼기 (다른 설정은 안 건드려요)
67
+ shellbase start --no-hook # 물어보지 않고 시작
68
+ ```
69
+
70
+ 훅이 없을 때 쓰는 짐작 방식은 "한동안 화면이 쉼 없이 바쁘다가(3초 이상, 글자 2KB 이상) 갑자기
71
+ 4초간 조용해지면 끝난 것" 으로 봅니다. 그래서 `ls` 처럼 짧은 명령이나 타이핑에는 반응하지 않지만,
72
+ Claude Code 가 아닌 긴 명령(빌드 등)이 끝나도 알림이 올 수 있어요.
40
73
 
41
74
  ### 접속 승인 정책
42
75
 
@@ -130,17 +163,26 @@ shellbase start --no-restore # 복구하지 않고 이 폴더 세션 하나
130
163
 
131
164
  ### 폰에서 마이크로 말하기 (음성 → 글자)
132
165
 
133
- 폰 화면 오른쪽 아래 **마이크 버튼**을 누르고 말하면, 녹음이 **이 컴퓨터로** 전송돼서 컴퓨터에 떠 있는
134
- 음성인식 서버가 글자로 바꿔줍니다. 기본값은 **말한 바로 보내기(엔터까지 자동)** 라서, 「완료」만
135
- 누르면 그대로 실행돼요 (Claude Code 에게 말로 시킬 편합니다). 확인하고 보내고 싶으면 명령창의
136
- `말한 뒤 바로 보내기 끄기` 로 예전 방식(받아쓴 글자를 보여주고 `보내기 ↵` 를 누르는 방식)으로 바꿀 수 있어요.
166
+ 폰 화면 오른쪽 아래 **마이크 버튼**을 누르고 말하면 글자로 바뀝니다. 기본값은 **말한 바로
167
+ 보내기(엔터까지 자동)** 라서, 「완료」만 누르면 그대로 실행돼요 (Claude Code 에게 말로 시킬 때
168
+ 편합니다). 확인하고 보내고 싶으면 명령창의 `말한 바로 보내기 끄기` 예전 방식(받아쓴 글자를
169
+ 보여주고 `보내기 ↵` 를 누르는 방식)으로 바꿀 수 있어요.
170
+
171
+ **받아쓰는 곳은 두 군데고, 준비된 쪽이 자동으로 쓰입니다.** 폰 화면에 어느 쪽인지 표시돼요.
172
+
173
+ | | 내 컴퓨터에서 받아쓰기 | 브라우저가 받아쓰기 |
174
+ |---|---|---|
175
+ | 언제 | 이 컴퓨터에 음성인식 서버가 떠 있을 때 | 그 밖의 모든 경우 |
176
+ | 준비 | 서버를 직접 띄워야 함 | **필요 없음** |
177
+ | 목소리가 나가는 곳 | 안 나감 (내 컴퓨터에서 끝) | 브라우저 회사 서버 (크롬=구글, 사파리=애플) |
178
+ | 품질 | 좋음 | 보통 |
137
179
 
138
- - **녹음은 다른 회사 서버로 나가지 않아요** — 폰 → (암호화된 실시간 채널) → 내 컴퓨터 → 내 음성인식 서버
139
180
  - 음성인식 서버는 시작할 때 자동으로 찾아요: `SHELLBASE_STT_URL` → `http://127.0.0.1:5005`
140
181
  → `http://172.17.0.1:5005`(도커 안에서 본 호스트) 순서
141
182
  - 주소가 다르면 `SHELLBASE_STT_URL=http://127.0.0.1:9000` 처럼 지정하고 다시 시작하세요.
142
183
  인식 언어는 `SHELLBASE_STT_LANGS=ko,en` 으로 바꿀 수 있어요 (기본 `ko`)
143
- - 서버가 없으면 마이크 기능만 꺼지고 나머지는 그대로 동작해요 (시작할 로그로 알려줍니다)
184
+ - 일부러 브라우저 쪽을 쓰고 싶으면(또는 5005 포트를 다른 프로그램이 쓰고 있으면)
185
+ `shellbase start --no-stt` 또는 `SHELLBASE_NO_STT=1`
144
186
  - 필요한 서버 형식: `GET /health` 와 `POST /transcribe_json` (`{audio_base64, languages}` → `{ok, text}`) —
145
187
  whisper 계열 서버면 대개 이 형태입니다
146
188
 
@@ -155,6 +197,9 @@ shellbase start --no-restore # 복구하지 않고 이 폴더 세션 하나
155
197
  - `node-pty` 를 사용해서 설치 시 네이티브 모듈을 컴파일합니다. macOS는 Xcode Command Line Tools,
156
198
  Linux는 `build-essential`(또는 배포판의 동급 패키지), Windows는 Visual Studio Build Tools가
157
199
  필요할 수 있어요.
200
+ - 폰이 (재)접속하면 **지금 화면 그대로** 복원돼요. 에이전트가 화면 상태를 따로 들고 있다가 그대로
201
+ 보내주기 때문에, Claude Code 같은 전체화면 앱을 보다가 잠깐 끊겼다 돌아와도 화면이 깨지지 않아요
202
+ (이전 300줄까지 함께 복원).
158
203
  - 터미널 내용은 기기별 키로 암호화돼서 전송되고, 모바일에서 접속을 시도할 때마다 데스크톱에서
159
204
  직접 승인해야 입력이 가능합니다.
160
205
 
package/dist/agent.js CHANGED
@@ -9,10 +9,10 @@ import { SerializeAddon } from '@xterm/addon-serialize';
9
9
  const requireCjs = createRequire(import.meta.url);
10
10
  const { Terminal: HeadlessTerminal } = requireCjs('@xterm/headless');
11
11
  import { requireClient } from './auth.js';
12
- import { TERMINAL_CATEGORY, CONTROL_TOKEN } from './config.js';
12
+ import { TERMINAL_CATEGORY, CONTROL_TOKEN, SESSION_ENV } from './config.js';
13
13
  import { startSleepGuard, stopSleepGuard } from './sleep-guard.js';
14
14
  import { encryptFrame, decryptFrame, generateFrameKey } from './crypto.js';
15
- import { registerDevice, heartbeatDevice, unregisterDevice, listOnlineNames, renameDevice, } from './devices.js';
15
+ import { registerDevice, heartbeatDevice, unregisterDevice, listOnlineNames, renameDevice, markSessionDone, } from './devices.js';
16
16
  import { promptApproval } from './prompt.js';
17
17
  import { touchRecentDir } from './recent-dirs.js';
18
18
  import { listDirs } from './browse.js';
@@ -20,6 +20,8 @@ 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';
22
22
  import { IMAGE_CHUNK_TTL_MS, pruneImages, saveImage } from './images.js';
23
+ import { watchDoneNotes } from './notify.js';
24
+ import { hookAsked, hookState, installHook, rememberHookAsked } from './claude-hook.js';
23
25
  const HEARTBEAT_MS = 20_000;
24
26
  // 올린 사진 정리 주기 — 보관 기한이 하루라, 오래 켜둔 컴퓨터에서도 반나절 안에 치워진다
25
27
  const IMAGE_SWEEP_MS = 6 * 60 * 60 * 1000;
@@ -40,6 +42,18 @@ const PENALTY_INTERVAL_MS = 250;
40
42
  const MAX_SESSIONS = 20;
41
43
  // 저장 중인 파일 조각을 모아두는 시간 — 중간에 폰이 끊기면 조용히 버린다
42
44
  const WRITE_BUFFER_TTL_MS = 60_000;
45
+ // ── "작업이 끝났다" 를 화면 출력만 보고 짐작하는 규칙 ─────────────────────────────
46
+ // Claude Code 훅(notify.ts)이 걸려 있으면 정확한 신호가 오지만, 훅을 못 넣는 환경도 있어서
47
+ // 화면만 보고도 짐작한다. Claude Code 는 일하는 동안 회전 표시·중간 결과를 쉼 없이 그리다가,
48
+ // 대답을 마치면 뚝 멈춘다. 그래서 "한동안 끊이지 않고 출력이 오다가 갑자기 조용해지면 끝난 것"
49
+ // 으로 본다. `ls` 처럼 한 번에 확 뱉고 끝나는 명령은 '이어진 시간' 이 짧아서 걸러진다.
50
+ // 어디까지나 짐작이라 가끔 틀릴 수 있고, 그래서 훅을 걸어두는 쪽을 권한다.
51
+ const BUSY_GAP_MS = 1_000; // 이 안에 다음 출력이 오면 "계속 일하는 중" 으로 이어 본다
52
+ const BUSY_MIN_MS = 3_000; // 이만큼은 이어져야 "일하고 있었다" 로 친다
53
+ const IDLE_MS = 4_000; // 이만큼 조용하면 "끝났다" 로 본다
54
+ // 시간만 보면 "천천히 오래 타이핑한 것" 도 일하는 중으로 잘못 본다(글자마다 화면에 되비치므로).
55
+ // 실제 작업은 출력량이 비교가 안 되게 많아서(회전 표시·중간 결과·빌드 로그) 양으로 걸러낸다.
56
+ const BUSY_MIN_BYTES = 2048;
43
57
  // 폰의 폴더 탐색기를 열 때 "지금 셸이 있는 폴더"에서 시작하려면, 세션을 시작한 폴더가 아니라
44
58
  // 셸 프로세스의 실제 작업 폴더를 봐야 한다(사용자가 cd 로 옮겨 다니므로). 리눅스는 /proc 로 바로 알 수 있고,
45
59
  // 없는 OS(맥·윈도)에서는 세션 시작 폴더로 되돌아간다.
@@ -51,6 +65,14 @@ function liveCwd(session) {
51
65
  return session.cwd;
52
66
  }
53
67
  }
68
+ // 접속을 허용하면서 "이 컴퓨터가 지금 뭘 해줄 수 있는지" 를 함께 알려준다.
69
+ // 지금은 받아쓰기 하나뿐이다: 음성인식 서버가 있으면 폰이 녹음을 이리로 보내고(품질이 좋고
70
+ // 목소리가 컴퓨터 밖으로 안 나감), 없으면 폰이 브라우저 받아쓰기로 알아서 넘어간다.
71
+ // 목록(devices) 대신 여기에 실어 보내는 이유: 목록 테이블은 정해진 칸만 받는데 이건 그 칸이 없고,
72
+ // 어차피 접속하는 그 순간의 상태가 정확하다(서버를 도중에 켜고 끌 수 있으므로).
73
+ function capsPayload(session, sttReady) {
74
+ return encryptFrame(JSON.stringify({ stt: sttReady }), session.frameKey);
75
+ }
54
76
  function resolveShell() {
55
77
  if (process.platform === 'win32')
56
78
  return process.env.COMSPEC ?? 'powershell.exe';
@@ -69,6 +91,41 @@ function readControl(data, key) {
69
91
  return null;
70
92
  }
71
93
  }
94
+ // Claude Code 완료 알림 훅을 아직 안 걸어둔 사람에게 딱 한 번 물어본다.
95
+ // 남의 설정 파일을 손대는 일이라 말없이 하지 않는다. 화면 앞에 사람이 없는 곳(도커·서비스)에서는
96
+ // 물어볼 수가 없으니 조용히 넘어가고, `shellbase hook install` 로 언제든 직접 걸 수 있다.
97
+ async function offerClaudeHook(options) {
98
+ if (options.hook === false)
99
+ return;
100
+ const state = hookState();
101
+ if (state === 'installed')
102
+ return;
103
+ if (state === 'no-claude')
104
+ return;
105
+ if (state === 'unreadable') {
106
+ console.log('⚠️ Claude Code 설정 파일(~/.claude/settings.json)을 읽을 수 없어서 완료 알림 훅은 건너뛰어요.\n' +
107
+ ' 화면 출력을 보고 짐작하는 방식으로는 계속 알려드려요.');
108
+ return;
109
+ }
110
+ if (hookAsked())
111
+ return;
112
+ if (!process.stdin.isTTY) {
113
+ console.log('ℹ️ Claude Code 작업 완료를 더 정확히 알려면 `shellbase hook install` 을 한 번 실행해주세요.\n' +
114
+ ' 지금은 화면 출력을 보고 짐작하는 방식으로 알려드려요.');
115
+ return;
116
+ }
117
+ rememberHookAsked();
118
+ const yes = await promptApproval('\n🔔 Claude Code 가 작업을 마치면 폰에 알려드릴까요?\n' +
119
+ ' (~/.claude/settings.json 에 알림 한 줄을 넣어요. 원래 설정은 그대로 두고 백업도 남겨요) (y/N): ', 30_000);
120
+ if (!yes) {
121
+ console.log(' 넘어갈게요. 나중에 원하시면 `shellbase hook install` 로 걸 수 있어요.');
122
+ return;
123
+ }
124
+ const result = installHook();
125
+ console.log(result.ok ? ` ✅ ${result.message}` : ` ⚠️ ${result.message}`);
126
+ if (result.ok)
127
+ console.log(' 이미 켜져 있는 Claude Code 는 다시 시작해야 적용돼요.');
128
+ }
72
129
  export async function runAgent(options) {
73
130
  const { cb, memberId } = await requireClient();
74
131
  // 여기서 한 번 호출해서 토큰이 살아있는지 먼저 확인 (만료됐으면 세션을 띄우기 전에 알려주는 게 낫다)
@@ -141,6 +198,51 @@ export async function runAgent(options) {
141
198
  const wait = Math.max(0, interval - (Date.now() - session.lastSentAt));
142
199
  session.pendingFlush = setTimeout(() => void flush(session), wait);
143
200
  };
201
+ // ── 작업 완료 알림 ──────────────────────────────────────────────────────────
202
+ // 폰에 두 가지 방법으로 알린다:
203
+ // ① 실시간 신호 — 앱을 켜두고 있으면 곧바로 뜬다
204
+ // ② 목록 row 의 done_at — 나중에 앱을 열어봐도 "그 사이에 끝났다" 는 표시가 남아 있다
205
+ // ②가 없으면 폰이 꺼져 있던 동안 끝난 작업을 영영 모르게 되므로 둘 다 남긴다.
206
+ const announceDone = (session, why) => {
207
+ if (session.closing)
208
+ return;
209
+ const at = new Date().toISOString();
210
+ console.log(`✅ "${session.name}" — ${why}`);
211
+ void send({ kind: 'session_done', to: session.deviceId });
212
+ if (session.rowId) {
213
+ markSessionDone(cb, session.rowId, at).catch(() => {
214
+ // 목록에 못 남겨도 실시간 신호는 이미 갔다 — 세션 동작에는 영향 없음
215
+ });
216
+ }
217
+ };
218
+ // 출력이 이어지는지 끊겼는지를 따라가며, 조용해지는 순간을 잡는다 (규칙은 위 상수 설명 참고)
219
+ const trackBusy = (session, bytes) => {
220
+ const now = Date.now();
221
+ // 직전 출력과의 간격이 짧으면 "계속 이어지는 중", 오래 쉬었다 왔으면 거기서 새로 시작
222
+ if (now - session.lastOutputAt > BUSY_GAP_MS) {
223
+ session.busySince = now;
224
+ session.busyBytes = 0;
225
+ }
226
+ session.busyBytes += bytes;
227
+ session.lastOutputAt = now;
228
+ if (session.idleTimer)
229
+ clearTimeout(session.idleTimer);
230
+ session.idleTimer = setTimeout(() => {
231
+ session.idleTimer = null;
232
+ if (session.closing || session.hookSeen)
233
+ return;
234
+ // 짧게 뱉고 끝난 명령(ls 등)과, 오래 걸렸지만 출력이 거의 없던 것(타이핑 등)은 넘긴다
235
+ if (session.lastOutputAt - session.busySince < BUSY_MIN_MS)
236
+ return;
237
+ if (session.busyBytes < BUSY_MIN_BYTES)
238
+ return;
239
+ // 같은 정적에 두 번 알리지 않는다
240
+ if (session.lastOutputAt <= session.announcedFor)
241
+ return;
242
+ session.announcedFor = session.lastOutputAt;
243
+ announceDone(session, '작업이 끝난 것 같아요 (화면이 조용해졌어요)');
244
+ }, IDLE_MS);
245
+ };
144
246
  // stdin 은 프로세스에 하나뿐이라, 세션이 여러 개일 때 승인 프롬프트가 겹치지 않게 한 번에 하나씩 묻는다
145
247
  let approvalChain = Promise.resolve();
146
248
  function askApproval(session) {
@@ -155,6 +257,7 @@ export async function runAgent(options) {
155
257
  await send({
156
258
  kind: approved ? 'connect_approved' : 'connect_denied',
157
259
  to: session.deviceId,
260
+ ...(approved ? { data: capsPayload(session, sttUrl !== null) } : {}),
158
261
  });
159
262
  })
160
263
  .catch((err) => {
@@ -176,8 +279,10 @@ export async function runAgent(options) {
176
279
  }
177
280
  if (!stat.isDirectory())
178
281
  throw new Error(`폴더가 아니라 파일이에요: ${cwd}`);
282
+ // 터미널을 띄우기 전에 미리 정해둔다 — 이 값을 세션 환경변수로도 심어야 하기 때문 (아래 env 참고)
283
+ const deviceId = `desktop-${crypto.randomUUID()}`;
179
284
  const session = {
180
- deviceId: `desktop-${crypto.randomUUID()}`,
285
+ deviceId,
181
286
  frameKey: generateFrameKey(),
182
287
  name: explicitName ?? defaultName(cwd),
183
288
  cwd,
@@ -187,7 +292,10 @@ export async function runAgent(options) {
187
292
  cols: 80,
188
293
  rows: 24,
189
294
  cwd,
190
- env: process.env,
295
+ // 이 터미널이 어느 세션인지 환경변수로 심어둔다. 이 안에서 실행되는 모든 것(Claude Code,
296
+ // 그리고 Claude Code 가 끝날 때 실행하는 `shellbase notify`)이 그대로 물려받으므로,
297
+ // 알림이 왔을 때 어느 세션인지 바로 알 수 있다 (notify.ts).
298
+ env: { ...process.env, [SESSION_ENV]: deviceId },
191
299
  }),
192
300
  screen: new HeadlessTerminal({
193
301
  cols: 80,
@@ -203,6 +311,12 @@ export async function runAgent(options) {
203
311
  sendPenaltyUntil: 0,
204
312
  pendingFlush: null,
205
313
  closing: false,
314
+ busySince: 0,
315
+ busyBytes: 0,
316
+ lastOutputAt: 0,
317
+ idleTimer: null,
318
+ announcedFor: 0,
319
+ hookSeen: false,
206
320
  };
207
321
  session.screen.loadAddon(session.serializer);
208
322
  session.pty.onData((data) => {
@@ -210,6 +324,7 @@ export async function runAgent(options) {
210
324
  // 화면 상태를 그대로 따라 그려둔다 (재접속 복원용)
211
325
  session.screen.write(data);
212
326
  scheduleFlush(session);
327
+ trackBusy(session, data.length);
213
328
  });
214
329
  session.pty.onExit(() => {
215
330
  console.log(`"${session.name}" 세션의 셸이 종료돼서 세션도 닫아요.`);
@@ -243,6 +358,8 @@ export async function runAgent(options) {
243
358
  session.closing = true;
244
359
  if (session.pendingFlush !== null)
245
360
  clearTimeout(session.pendingFlush);
361
+ if (session.idleTimer !== null)
362
+ clearTimeout(session.idleTimer);
246
363
  await flush(session);
247
364
  sessions.delete(session.deviceId);
248
365
  if (session.rowId) {
@@ -277,10 +394,13 @@ export async function runAgent(options) {
277
394
  shuttingDown = true;
278
395
  clearInterval(heartbeat);
279
396
  clearInterval(imageSweep);
397
+ stopNotes();
280
398
  for (const session of [...sessions.values()]) {
281
399
  session.closing = true;
282
400
  if (session.pendingFlush !== null)
283
401
  clearTimeout(session.pendingFlush);
402
+ if (session.idleTimer !== null)
403
+ clearTimeout(session.idleTimer);
284
404
  await flush(session);
285
405
  if (session.rowId) {
286
406
  try {
@@ -314,6 +434,20 @@ export async function runAgent(options) {
314
434
  await cb.realtime.connect({ userId: `agent-${crypto.randomUUID()}` });
315
435
  const channel = await cb.realtime.subscribe(TERMINAL_CATEGORY);
316
436
  // 이 채널은 앱의 모든 멤버가 공유하므로, 프레임 내용(data)은 세션별 키로 암호화해서 주고받는다.
437
+ // Claude Code 훅이 남긴 "대답 마쳤어요" 쪽지를 받는다 (notify.ts).
438
+ // 훅에서 정확한 신호가 온 세션은, 화면만 보고 짐작하던 쪽을 꺼서 알림이 두 번 뜨지 않게 한다.
439
+ const stopNotes = watchDoneNotes((note) => {
440
+ const session = sessions.get(note.sessionId);
441
+ if (!session || session.closing)
442
+ return;
443
+ session.hookSeen = true;
444
+ if (session.idleTimer) {
445
+ clearTimeout(session.idleTimer);
446
+ session.idleTimer = null;
447
+ }
448
+ session.announcedFor = session.lastOutputAt;
449
+ announceDone(session, note.body || 'Claude Code 가 대답을 마쳤어요');
450
+ });
317
451
  const stopMessages = channel.onMessage((msg) => {
318
452
  const frame = msg.data;
319
453
  if (!frame)
@@ -347,7 +481,11 @@ export async function runAgent(options) {
347
481
  // 폰이 "승인 대기 중" 화면에 갇히는 일이 생겼음 — 자동 승인은 기다릴 게 없으므로 항상 응답)
348
482
  if (!requireApproval) {
349
483
  session.inputApproved = true;
350
- void send({ kind: 'connect_approved', to: session.deviceId });
484
+ void send({
485
+ kind: 'connect_approved',
486
+ to: session.deviceId,
487
+ data: capsPayload(session, sttUrl !== null),
488
+ });
351
489
  return;
352
490
  }
353
491
  if (session.approvalInFlight)
@@ -627,8 +765,9 @@ export async function runAgent(options) {
627
765
  void findSttServer().then((url) => {
628
766
  sttUrl = url;
629
767
  console.log(url
630
- ? `🎤 음성인식 서버를 찾았어요: ${url} — 폰에서 마이크로 말할 있어요.`
631
- : '🎤 음성인식 서버가 없어요 마이크 기능은 꺼진 상태예요 (SHELLBASE_STT_URL 로 주소를 지정할 수 있어요).');
768
+ ? `🎤 컴퓨터에서 받아써요: ${url} — 목소리가 컴퓨터 밖으로 나가지 않아요.`
769
+ : '🎤 이 컴퓨터에는 음성인식 서버가 없어서, 폰이 브라우저 받아쓰기로 대신해요.\n' +
770
+ ' (직접 띄운 서버를 쓰려면 SHELLBASE_STT_URL 로 주소를 지정하세요)');
632
771
  });
633
772
  // 올린 사진은 하루가 지나면 치운다. 예전엔 "새 사진을 저장할 때"만 청소해서, 사진을 한동안 안 올리면
634
773
  // 기한이 지난 파일이 계속 남아 있었다 — 시작할 때 한 번, 그리고 켜져 있는 동안 주기적으로도 돌린다.
@@ -652,6 +791,7 @@ export async function runAgent(options) {
652
791
  console.log(requireApproval
653
792
  ? '접속할 때마다 이 화면에서 승인(y)을 물어봐요. (--require-approval)'
654
793
  : '내 계정으로 로그인한 폰에서는 바로 입력할 수 있어요. 매번 확인받고 싶으면 --require-approval 로 실행하세요.');
794
+ await offerClaudeHook(options);
655
795
  // ── 지난번에 열려 있던 세션 복구 (컴퓨터·컨테이너 재시작 후 자동으로 원래 상태로)
656
796
  if (options.restore === false) {
657
797
  console.log('이전 세션 복구는 건너뛰어요 (--no-restore)');
@@ -0,0 +1,145 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { CONFIG_DIR } from './config.js';
5
+ // Claude Code 에 "대답을 마치면 shellbase 에 알려줘" 를 걸어두는 부분.
6
+ //
7
+ // Claude Code 는 설정 파일(~/.claude/settings.json)의 Stop 훅에 적힌 명령을 대답이 끝날 때마다
8
+ // 실행해준다. 여기에 `shellbase notify` 한 줄을 넣어두면 완료 시점을 정확히 알 수 있다.
9
+ // (넣지 않아도 화면 출력을 보고 짐작은 하지만, 훅이 있으면 훨씬 정확하다 — agent.ts 참고)
10
+ //
11
+ // 남의 설정 파일을 손대는 일이라 규칙을 지킨다:
12
+ // · 원래 있던 내용은 하나도 건드리지 않고 우리 줄만 더한다 (다른 알림 훅과 함께 잘 동작한다)
13
+ // · 고치기 전에 같은 폴더에 백업을 남긴다
14
+ // · JSON 이 깨져 있으면 아무것도 하지 않는다 (섣불리 고치면 설정 전체가 날아간다)
15
+ const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
16
+ const HOOK_COMMAND = 'shellbase notify';
17
+ // 물어봤는데 "아니요" 한 사람에게 매번 다시 묻지 않기 위해 답을 기억해둔다
18
+ const CHOICE_PATH = path.join(CONFIG_DIR, 'claude-hook.json');
19
+ function readSettings() {
20
+ let raw;
21
+ try {
22
+ raw = fs.readFileSync(SETTINGS_PATH, 'utf8');
23
+ }
24
+ catch {
25
+ // 파일이 아직 없는 건 정상 — 빈 설정에서 시작한다
26
+ return { ok: true, value: {} };
27
+ }
28
+ if (raw.trim() === '')
29
+ return { ok: true, value: {} };
30
+ try {
31
+ const parsed = JSON.parse(raw);
32
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
33
+ return { ok: false };
34
+ return { ok: true, value: parsed };
35
+ }
36
+ catch {
37
+ return { ok: false };
38
+ }
39
+ }
40
+ function stopGroups(settings) {
41
+ const hooks = settings.hooks;
42
+ if (!hooks || typeof hooks !== 'object')
43
+ return [];
44
+ const stop = hooks.Stop;
45
+ return Array.isArray(stop) ? stop : [];
46
+ }
47
+ function hasOurHook(groups) {
48
+ return groups.some((group) => (group?.hooks ?? []).some((entry) => (entry?.command ?? '').includes(HOOK_COMMAND)));
49
+ }
50
+ export function hookState() {
51
+ if (!fs.existsSync(path.join(os.homedir(), '.claude')))
52
+ return 'no-claude';
53
+ const settings = readSettings();
54
+ if (!settings.ok)
55
+ return 'unreadable';
56
+ return hasOurHook(stopGroups(settings.value)) ? 'installed' : 'absent';
57
+ }
58
+ export function installHook() {
59
+ const settings = readSettings();
60
+ if (!settings.ok) {
61
+ return {
62
+ ok: false,
63
+ message: `${SETTINGS_PATH} 의 내용이 올바른 JSON 이 아니라서 건드리지 않았어요. 파일을 고친 뒤 \`shellbase hook install\` 로 다시 시도해주세요.`,
64
+ };
65
+ }
66
+ const value = settings.value;
67
+ const groups = stopGroups(value);
68
+ if (hasOurHook(groups))
69
+ return { ok: true, message: '이미 걸려 있어요.' };
70
+ // 원래 있던 훅들 뒤에 우리 것만 더한다
71
+ const nextGroups = [
72
+ ...groups,
73
+ { hooks: [{ type: 'command', command: HOOK_COMMAND }] },
74
+ ];
75
+ const hooks = value.hooks && typeof value.hooks === 'object' && !Array.isArray(value.hooks)
76
+ ? { ...value.hooks }
77
+ : {};
78
+ hooks.Stop = nextGroups;
79
+ const next = { ...value, hooks };
80
+ try {
81
+ fs.mkdirSync(path.dirname(SETTINGS_PATH), { recursive: true });
82
+ // 고치기 전 백업 — 뭔가 잘못돼도 되돌릴 수 있게
83
+ if (fs.existsSync(SETTINGS_PATH)) {
84
+ fs.copyFileSync(SETTINGS_PATH, `${SETTINGS_PATH}.shellbase-backup`);
85
+ }
86
+ fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(next, null, 2)}\n`);
87
+ }
88
+ catch (err) {
89
+ return { ok: false, message: `설정 파일을 저장하지 못했어요: ${err.message}` };
90
+ }
91
+ return {
92
+ ok: true,
93
+ message: `${SETTINGS_PATH} 에 완료 알림 훅을 넣었어요. (원본은 settings.json.shellbase-backup 에 백업)`,
94
+ };
95
+ }
96
+ export function removeHook() {
97
+ const settings = readSettings();
98
+ if (!settings.ok) {
99
+ return { ok: false, message: `${SETTINGS_PATH} 의 JSON 이 깨져 있어서 건드리지 않았어요.` };
100
+ }
101
+ const value = settings.value;
102
+ const groups = stopGroups(value);
103
+ if (!hasOurHook(groups))
104
+ return { ok: true, message: '걸려 있지 않아요 — 지울 것이 없어요.' };
105
+ // 우리 명령만 빼고, 그 결과 빈 껍데기가 된 묶음도 정리한다 (남의 훅은 그대로 둔다)
106
+ const nextGroups = groups
107
+ .map((group) => ({
108
+ ...group,
109
+ hooks: (group?.hooks ?? []).filter((entry) => !(entry?.command ?? '').includes(HOOK_COMMAND)),
110
+ }))
111
+ .filter((group) => group.hooks.length > 0);
112
+ const hooks = { ...value.hooks };
113
+ if (nextGroups.length > 0)
114
+ hooks.Stop = nextGroups;
115
+ else
116
+ delete hooks.Stop;
117
+ const next = { ...value, hooks };
118
+ try {
119
+ fs.copyFileSync(SETTINGS_PATH, `${SETTINGS_PATH}.shellbase-backup`);
120
+ fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(next, null, 2)}\n`);
121
+ }
122
+ catch (err) {
123
+ return { ok: false, message: `설정 파일을 저장하지 못했어요: ${err.message}` };
124
+ }
125
+ return { ok: true, message: `${SETTINGS_PATH} 에서 완료 알림 훅을 뺐어요.` };
126
+ }
127
+ // "물어보지 마세요" 를 기억해두는 부분 — 한 번 거절한 사람에게 매번 묻지 않기 위해서다
128
+ export function hookAsked() {
129
+ try {
130
+ return JSON.parse(fs.readFileSync(CHOICE_PATH, 'utf8'))?.asked === true;
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ }
136
+ export function rememberHookAsked() {
137
+ try {
138
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
139
+ fs.writeFileSync(CHOICE_PATH, JSON.stringify({ asked: true }, null, 2), { mode: 0o600 });
140
+ }
141
+ catch {
142
+ // 기억 못 해도 큰 문제는 아니다 — 다음에 한 번 더 물어볼 뿐
143
+ }
144
+ }
145
+ export const CLAUDE_SETTINGS_PATH = SETTINGS_PATH;
package/dist/cli.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
2
3
  import { Command } from 'commander';
3
4
  import { login, logout, whoami } from './auth.js';
4
5
  import { runAgent } from './agent.js';
6
+ import { writeDoneNote } from './notify.js';
7
+ import { hookState, installHook, removeHook, CLAUDE_SETTINGS_PATH } from './claude-hook.js';
5
8
  // 실시간 기능(realtime.subscribe)이 Node 의 네이티브 WebSocket(Node 22+)에 의존해서,
6
9
  // Node 20 에서는 조용히 SSE 모드로 떨어져 "네트워크가 막혔다"는 헷갈리는 에러만 남기고 죽는다.
7
10
  // 미리 버전을 확인해서 원인을 바로 알려준다.
@@ -11,8 +14,14 @@ if (major < 22) {
11
14
  'https://nodejs.org 에서 최신 버전을 설치하거나, nvm 을 쓰신다면 `nvm install 22 && nvm use 22` 후 다시 시도해주세요.');
12
15
  process.exit(1);
13
16
  }
17
+ // 설치 스크립트가 "제대로 깔렸는지" 확인할 때 `shellbase --version` 을 부른다.
18
+ const require = createRequire(import.meta.url);
19
+ const { version } = require('../package.json');
14
20
  const program = new Command();
15
- program.name('shellbase').description('내 컴퓨터 터미널을 폰에서 접속할 수 있게 해주는 에이전트');
21
+ program
22
+ .name('shellbase')
23
+ .description('내 컴퓨터 터미널을 폰에서 접속할 수 있게 해주는 에이전트')
24
+ .version(version, '-v, --version', '설치된 버전 보기');
16
25
  program
17
26
  .command('login')
18
27
  .description('구글 계정으로 로그인 (처음이면 자동으로 가입도 돼요)')
@@ -39,9 +48,59 @@ program
39
48
  .option('--require-approval', '폰에서 접속할 때마다 이 컴퓨터에서 y 로 승인해야 입력이 가능해요 (기본: 내 계정이면 바로 허용)')
40
49
  .option('--auto-approve', '(옛 옵션 — 이제 기본 동작이라 넣지 않아도 같아요)')
41
50
  .option('--no-restore', '지난번에 열려 있던 세션들을 자동으로 다시 띄우지 않아요')
51
+ .option('--no-hook', 'Claude Code 완료 알림 훅을 걸지 물어보지 않아요')
52
+ .option('--no-stt', '이 컴퓨터의 음성인식 서버를 쓰지 않아요 (폰이 브라우저 받아쓰기로 넘어가요)')
42
53
  .action(async (opts) => {
54
+ // 아래쪽(stt.ts)에서 보는 값 하나로 모아둔다 — 환경변수로도, 옵션으로도 끌 수 있게
55
+ if (opts.stt === false)
56
+ process.env.SHELLBASE_NO_STT = '1';
43
57
  await runAgent(opts);
44
58
  });
59
+ // Claude Code 의 Stop 훅이 실행하는 명령. 우리가 띄운 터미널 안에서 돌기 때문에 세션을 가리키는
60
+ // 환경변수를 물려받고 있어서, 쪽지 한 장만 남기면 에이전트가 알아서 폰에 알려준다 (notify.ts).
61
+ program
62
+ .command('notify')
63
+ .description('지금 세션에서 "작업이 끝났다"고 폰에 알려요 (Claude Code 훅이 자동으로 실행해요)')
64
+ .option('--title <title>', '알림 제목', 'Claude Code')
65
+ .option('--body <body>', '알림 내용', '작업이 끝났어요')
66
+ .action((opts) => {
67
+ // 세션 밖(그냥 터미널)에서 실행되면 알릴 곳이 없다 — 조용히 넘어간다.
68
+ // 훅은 Claude Code 를 쓸 때마다 실행되므로, 여기서 시끄럽게 굴면 안 된다.
69
+ writeDoneNote(opts.title, opts.body);
70
+ });
71
+ const hook = program
72
+ .command('hook')
73
+ .description('Claude Code 작업 완료 알림 켜기/끄기 (~/.claude/settings.json)');
74
+ hook
75
+ .command('install')
76
+ .description('Claude Code 가 대답을 마치면 폰에 알리도록 설정해요')
77
+ .action(() => {
78
+ const result = installHook();
79
+ console.log(result.ok ? `✅ ${result.message}` : `⚠️ ${result.message}`);
80
+ if (result.ok)
81
+ console.log('이미 켜져 있는 Claude Code 는 다시 시작해야 적용돼요.');
82
+ });
83
+ hook
84
+ .command('remove')
85
+ .description('완료 알림 설정을 다시 빼요 (다른 설정은 그대로 둬요)')
86
+ .action(() => {
87
+ const result = removeHook();
88
+ console.log(result.ok ? `✅ ${result.message}` : `⚠️ ${result.message}`);
89
+ });
90
+ hook
91
+ .command('status')
92
+ .description('지금 완료 알림이 걸려 있는지 확인해요')
93
+ .action(() => {
94
+ const state = hookState();
95
+ if (state === 'installed')
96
+ console.log(`✅ 걸려 있어요 — ${CLAUDE_SETTINGS_PATH}`);
97
+ else if (state === 'absent')
98
+ console.log(`⬜ 아직 안 걸려 있어요. \`shellbase hook install\` 로 걸 수 있어요.`);
99
+ else if (state === 'no-claude')
100
+ console.log('⬜ 이 컴퓨터에서 Claude Code 를 쓴 흔적이 없어요 (~/.claude 폴더가 없어요).');
101
+ else
102
+ console.log(`⚠️ ${CLAUDE_SETTINGS_PATH} 의 내용이 올바른 JSON 이 아니에요.`);
103
+ });
45
104
  program.parseAsync(process.argv).catch((err) => {
46
105
  console.error('실행 중 문제가 생겼어요:', err instanceof Error ? err.message : err);
47
106
  process.exit(1);
package/dist/config.js CHANGED
@@ -15,3 +15,9 @@ export const CREDENTIALS_PATH = path.join(CONFIG_DIR, 'credentials.json');
15
15
  // 프레임 전체가 세션별 키로 암호화돼 있어서, 이 문자열이 제대로 풀린다는 건 보낸 쪽이 그 키
16
16
  // (= devices 테이블의 내 row, RLS 로 주인만 읽을 수 있음)를 가지고 있었다는 증거가 된다.
17
17
  export const CONTROL_TOKEN = 'shellbase-control-v1';
18
+ // Claude Code 가 "대답을 마쳤다"고 알려오는 쪽지를 놓아두는 곳 (notify.ts).
19
+ // 훅이 실행하는 `shellbase notify` 가 여기에 파일을 하나 떨어뜨리면, 에이전트가 주워서 지운다.
20
+ export const NOTIFY_DIR = path.join(CONFIG_DIR, 'notify');
21
+ // 세션 터미널에 심어두는 환경변수 이름 — Claude Code 훅은 우리가 띄운 셸의 자식 프로세스라
22
+ // 이 값을 그대로 물려받는다. 덕분에 "어느 세션이 끝났는지"를 따로 알아낼 필요가 없다.
23
+ export const SESSION_ENV = 'SHELLBASE_SESSION_ID';
package/dist/devices.js CHANGED
@@ -51,3 +51,9 @@ export async function listOnlineNames(cb) {
51
51
  export async function renameDevice(cb, rowId, deviceName) {
52
52
  await cb.database.updateData(DEVICES_TABLE_ID, rowId, { data: { device_name: deviceName } });
53
53
  }
54
+ // "이 세션에서 작업이 끝났다" 는 표시를 목록 row 에 남긴다.
55
+ // 실시간 신호와 별개로 이걸 남겨두는 이유: 폰이 꺼져 있거나 앱을 닫아둔 사이에 끝난 작업도
56
+ // 나중에 앱을 열었을 때 "● 끝남" 으로 보여주기 위해서다 (실시간 신호는 그 순간 놓치면 끝).
57
+ export async function markSessionDone(cb, rowId, at) {
58
+ await cb.database.updateData(DEVICES_TABLE_ID, rowId, { data: { done_at: at } });
59
+ }
package/dist/notify.js ADDED
@@ -0,0 +1,114 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { NOTIFY_DIR, SESSION_ENV } from './config.js';
5
+ // 오래 남은 쪽지는 무시한다 — 에이전트를 껐다 켠 뒤 며칠 전 알림이 갑자기 뜨면 오히려 헷갈린다
6
+ const NOTE_STALE_MS = 60_000;
7
+ function isNote(value) {
8
+ if (!value || typeof value !== 'object')
9
+ return false;
10
+ const note = value;
11
+ return (typeof note.sessionId === 'string' &&
12
+ typeof note.title === 'string' &&
13
+ typeof note.body === 'string' &&
14
+ typeof note.at === 'string');
15
+ }
16
+ // `shellbase notify` 쪽에서 부른다. 쪽지를 남길 수 없으면(세션 밖에서 실행 등) false 를 돌려준다.
17
+ export function writeDoneNote(title, body) {
18
+ const sessionId = process.env[SESSION_ENV];
19
+ if (!sessionId)
20
+ return false;
21
+ const note = { sessionId, title, body, at: new Date().toISOString() };
22
+ try {
23
+ fs.mkdirSync(NOTIFY_DIR, { recursive: true, mode: 0o700 });
24
+ // 에이전트가 반쯤 쓰인 파일을 주워 읽지 않도록, 다 쓴 뒤 이름을 바꿔 넣는다
25
+ const temp = path.join(NOTIFY_DIR, `.${crypto.randomUUID()}.tmp`);
26
+ const final = path.join(NOTIFY_DIR, `${crypto.randomUUID()}.json`);
27
+ fs.writeFileSync(temp, JSON.stringify(note), { mode: 0o600 });
28
+ fs.renameSync(temp, final);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ // 에이전트 쪽에서 부른다. 쪽지가 도착할 때마다 onNote 를 불러주고, 멈추는 함수를 돌려준다.
36
+ //
37
+ // 파일 감시(fs.watch)는 편하지만 환경에 따라(도커의 특정 파일시스템, 네트워크 드라이브 등)
38
+ // 아예 동작하지 않는 경우가 있어서, 주기적으로 직접 훑어보는 방법을 함께 둔다 — 감시가 되면
39
+ // 즉시, 안 되더라도 늦어도 몇 초 안에는 전달된다.
40
+ const SWEEP_MS = 3_000;
41
+ export function watchDoneNotes(onNote) {
42
+ try {
43
+ fs.mkdirSync(NOTIFY_DIR, { recursive: true, mode: 0o700 });
44
+ }
45
+ catch {
46
+ // 폴더를 못 만들면 훅 알림은 못 받지만 세션 자체는 정상 동작해야 한다 (화면 감지로 대신함)
47
+ return () => { };
48
+ }
49
+ let stopped = false;
50
+ // 감시와 주기 훑기가 같은 쪽지를 두 번 집는 일이 없도록, 집는 순간 지우고 이름을 기억해둔다
51
+ const taken = new Set();
52
+ function sweep() {
53
+ if (stopped)
54
+ return;
55
+ let names;
56
+ try {
57
+ names = fs.readdirSync(NOTIFY_DIR);
58
+ }
59
+ catch {
60
+ return;
61
+ }
62
+ for (const name of names) {
63
+ if (!name.endsWith('.json') || taken.has(name))
64
+ continue;
65
+ taken.add(name);
66
+ const file = path.join(NOTIFY_DIR, name);
67
+ let raw;
68
+ try {
69
+ raw = fs.readFileSync(file, 'utf8');
70
+ }
71
+ catch {
72
+ continue;
73
+ }
74
+ try {
75
+ fs.unlinkSync(file);
76
+ }
77
+ catch {
78
+ // 못 지워도 taken 에 있으니 다시 집지는 않는다
79
+ }
80
+ taken.delete(name);
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(raw);
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ if (!isNote(parsed))
89
+ continue;
90
+ if (Date.now() - new Date(parsed.at).getTime() > NOTE_STALE_MS)
91
+ continue;
92
+ onNote(parsed);
93
+ }
94
+ }
95
+ let watcher = null;
96
+ try {
97
+ watcher = fs.watch(NOTIFY_DIR, () => sweep());
98
+ // 감시 자체가 실패해도(권한 등) 주기 훑기로 계속 동작해야 한다
99
+ watcher.on('error', () => { });
100
+ }
101
+ catch {
102
+ watcher = null;
103
+ }
104
+ const timer = setInterval(sweep, SWEEP_MS);
105
+ // 이 타이머 때문에 프로세스가 안 꺼지는 일이 없게 한다
106
+ timer.unref?.();
107
+ // 에이전트가 꺼져 있는 동안 쌓인 쪽지가 있을 수 있으니 한 번 훑고 시작한다
108
+ sweep();
109
+ return () => {
110
+ stopped = true;
111
+ clearInterval(timer);
112
+ watcher?.close();
113
+ };
114
+ }
package/dist/stt.js CHANGED
@@ -24,6 +24,10 @@ async function isAlive(baseUrl) {
24
24
  }
25
25
  // 시작할 때 한 번 찾아두고, 못 찾으면 요청이 올 때 다시 한 번 찾아본다(그 사이에 켰을 수도 있으니)
26
26
  export async function findSttServer() {
27
+ // 이 컴퓨터의 음성인식 서버를 일부러 안 쓰고 싶을 때 (폰이 브라우저 받아쓰기로 넘어간다).
28
+ // 5005 포트를 다른 프로그램이 쓰고 있는 경우에도 필요하다.
29
+ if (process.env.SHELLBASE_NO_STT)
30
+ return null;
27
31
  for (const url of candidates()) {
28
32
  if (await isAlive(url))
29
33
  return url;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellbase",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {