shellbase 0.2.0 → 0.3.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
@@ -41,11 +41,30 @@ shellbase start --dir ~/projects/my-app --name "내 노트북"
41
41
  `--auto-approve` 를 쓰면 폰에서 접속할 때마다 물어보지 않고 바로 승인해요. 편하지만, 내 계정에
42
42
  로그인할 수 있는 사람은 누구나 바로 입력할 수 있게 되니 신뢰할 수 있는 환경에서만 쓰세요.
43
43
 
44
- ### 같은 컴퓨터에서 여러 동시에 켜기
44
+ ### 여러 세션을 프로세스가 관리해요
45
45
 
46
- 터미널을 새로 열어서 다른 폴더로 `shellbase start` 를 한 번 실행하면, 별개의 세션으로 등록돼서
47
- 목록에 폴더별로 따로 떠요(예: `내PC · project-a`, `내PC · project-b`). 프로젝트마다 세션을 하나씩
48
- 켜두고 폰에서 왔다갔다 접속할 수 있어요.
46
+ `shellbase start` 를 한 번 실행하면 프로세스 하나가 뜨고, 그 안에서 **터미널(세션)을 여러 개** 관리해요.
47
+ 폰에서 `+ 세션` 으로 늘려도 프로세스는 그대로 하나입니다.
48
+
49
+ - 세션 2개 기준 메모리 **약 85MB** (예전엔 세션마다 프로세스를 띄워서 200MB였어요)
50
+ - 실시간 연결도 세션 수와 무관하게 **1개**만 사용해요
51
+ - 프로젝트마다 세션을 하나씩 켜두고 폰 상단 칩으로 왔다갔다 하면 됩니다
52
+ - `Ctrl+C` 를 누르면 그 프로세스의 **모든 세션**이 함께 정상 종료돼요 (열려 있던 목록은 기억해둬요 — 아래 참고)
53
+ - 터미널을 새로 열어 `shellbase start` 를 또 실행해도 됩니다 (프로세스가 하나 더 생기고, 이미 켜져 있는
54
+ 세션을 중복으로 복구하지는 않아요)
55
+
56
+ ### 재시작하면 세션이 자동으로 돌아와요
57
+
58
+ 열려 있던 세션 목록은 `~/.shellbase/open-sessions.json` 에 기록돼요. 컴퓨터·컨테이너가 재시작되거나
59
+ `Ctrl+C` 로 껐다가 `shellbase start` 를 다시 실행하면, **그때 열려 있던 세션들을 자동으로 다시 띄워요.**
60
+
61
+ ```bash
62
+ shellbase start # 이전 세션들도 함께 복구
63
+ shellbase start --no-restore # 복구하지 않고 이 폴더 세션 하나만
64
+ ```
65
+
66
+ - 폰에서 **일부러 끈 세션은 복구 대상에서 빠져요** (다시 열리지 않아요)
67
+ - 폴더가 사라졌거나, 이미 켜져 있는 세션은 조용히 건너뜁니다
49
68
 
50
69
  ### 폰에서 세션 열기 · 전환 · 끄기 (컴퓨터에 손 안 대고)
51
70
 
@@ -64,14 +83,15 @@ shellbase start --dir ~/projects/my-app --name "내 노트북"
64
83
  - **폰에서 연 세션은 자동 승인으로 열려요.** 컴퓨터 앞에 `y` 를 눌러줄 사람이 없기 때문이에요.
65
84
  (요청을 보낸 쪽이 그 세션의 암호화 키를 가지고 있는지 먼저 확인해요 — 그 키는 내 계정만 읽을 수
66
85
  있어서, 다른 사람은 남의 세션을 열거나 끌 수 없어요.)
67
- - **폰에서 연 세션은 부모 세션과 독립적이에요.** 처음 세션을 `Ctrl+C` 꺼도 폰에서 만든 세션은
68
- 계속 살아있어요 (각각 폰에서 따로 끄면 돼요).
69
-
70
- > ⚠️ **도커로 띄운 세션을 폰에서 끄면 도커가 다시 살려요.** `--restart unless-stopped` 는 정상 종료
71
- > (exit 0)에도 컨테이너를 자동 재시작하거든요(실제로 확인한 동작이에요). 그래서 도커로 돌리는
72
- > 세션은 폰에서 끄면 **사실상 재시작**이 되고, 이름의 세션으로 목록에 다시 나타나요. 완전히
73
- > 내리려면 컴퓨터에서 `docker stop shellbase-agent` 쓰세요. 폰에서 `+ 세션` 으로 만든 세션들은
74
- > 도커와 무관한 일반 프로세스라 폰에서 끄면 그대로 꺼져요.
86
+ - **폰에서 연 세션도 같은 프로세스가 관리해요.** 그래서 컴퓨터에서 `Ctrl+C` 누르면 모든 세션이 함께
87
+ 종료돼요(그 대신 다음 실행 자동 복구됩니다). 세션을 하나만 끄고 싶으면 폰에서 그 세션의 `✕` 를
88
+ 쓰세요 — 나머지 세션은 그대로 유지돼요.
89
+
90
+ > ⚠️ **도커로 돌릴 때, 마지막 세션을 폰에서 끄면 도커가 다시 띄워요.** 세션이 0개가 되면 프로세스가
91
+ > 종료되는데, `--restart unless-stopped` 정상 종료(exit 0)에도 컨테이너를 자동 재시작하기 때문이에요
92
+ > (실제로 확인한 동작). 마지막 세션 끄기는 **사실상 재시작**이 됩니다. 세션이 여러 개일 때 하나만
93
+ > 끄는 프로세스가 계속 살아있으니 그대로 꺼져요. 컨테이너를 완전히 내리려면 컴퓨터에서
94
+ > `docker stop shellbase-agent` 를 쓰세요.
75
95
 
76
96
  ## 참고
77
97
 
package/dist/agent.js CHANGED
@@ -12,15 +12,31 @@ import { promptApproval } from './prompt.js';
12
12
  import { touchRecentDir } from './recent-dirs.js';
13
13
  import { listDirs } from './browse.js';
14
14
  import { loadOpenSessions, saveOpenSessions } from './open-sessions.js';
15
+ import { loadOrCreateHostId } from './host.js';
15
16
  const HEARTBEAT_MS = 20_000;
16
17
  const APPROVAL_TIMEOUT_MS = 30_000;
17
18
  const SCROLLBACK_MAX = 16_000;
18
- // 서버가 전송 속도를 최소 100ms 간격으로 제한해서(RATE_LIMITED), 그보다 짧은 간격으로 보내면 출력이
19
- // 많은 순간에 거부될 있음. 그렇다고 매번 고정 간격을 기다리면 한두 글자 출력도 불필요하게 지연되니,
20
- // "마지막 전송 간격이 지났으면 바로 보내고, 아니면 그만큼만 기다렸다 모아서 보낸다"로 처리한다.
21
- const MIN_SEND_INTERVAL_MS = 120;
19
+ // 출력 전송 최소 간격. "마지막 전송 간격이 지났으면 바로 보내고, 아니면 그만큼만 기다렸다 모아서
20
+ // 보낸다"(leading-edge 스로틀). 예전엔 문서의 "최소 100ms" 보수적으로 잡아 120ms 뒀는데, 실측해보니
21
+ // 서버가 40회 연속 전송도 거부 없이 받아줬고 값이 타이핑 체감 지연의 주범이었음(입력·출력 양쪽에서
22
+ // 각각 최대 120ms). 30ms 로 낮춰 체감을 줄이고, 출력이 몰릴 때는 여전히 초당 33회로 묶인다.
23
+ const MIN_SEND_INTERVAL_MS = 30;
24
+ // 그래도 서버가 거부하면(RATE_LIMITED 등) 잠시 간격을 넉넉히 벌려서 재시도한다
25
+ const SEND_PENALTY_MS = 500;
26
+ const PENALTY_INTERVAL_MS = 250;
22
27
  // 실수나 버그로 세션이 무한정 늘어나는 것을 막는 상한 (한 컴퓨터에서 이 이상 필요한 경우는 사실상 없음)
23
28
  const MAX_SESSIONS = 20;
29
+ // 폰의 폴더 탐색기를 열 때 "지금 셸이 있는 폴더"에서 시작하려면, 세션을 시작한 폴더가 아니라
30
+ // 셸 프로세스의 실제 작업 폴더를 봐야 한다(사용자가 cd 로 옮겨 다니므로). 리눅스는 /proc 로 바로 알 수 있고,
31
+ // 없는 OS(맥·윈도)에서는 세션 시작 폴더로 되돌아간다.
32
+ function liveCwd(session) {
33
+ try {
34
+ return fs.readlinkSync(`/proc/${session.pty.pid}/cwd`);
35
+ }
36
+ catch {
37
+ return session.cwd;
38
+ }
39
+ }
24
40
  function resolveShell() {
25
41
  if (process.platform === 'win32')
26
42
  return process.env.COMSPEC ?? 'powershell.exe';
@@ -44,13 +60,20 @@ function readControl(data, key) {
44
60
  }
45
61
  export async function runAgent(options) {
46
62
  const { cb, memberId } = await requireClient();
63
+ // 여기서 한 번 호출해서 토큰이 살아있는지 먼저 확인 (만료됐으면 세션을 띄우기 전에 알려주는 게 낫다)
47
64
  const me = await cb.auth.getMe();
48
- const accountAutoApprove = me.custom_data?.auto_approve === true;
49
- const autoApprove = options.autoApprove === true || accountAutoApprove;
65
+ // 입력 승인 정책: 도구의 보안 경계는 "내 ConnectBase 계정(구글 로그인)" 이다. 화면 출력은 원래부터
66
+ // 계정 주인만 있었고(세션 키가 RLS 로 보호되는 내 row 에만 있음), 실제로도 화면만 봐도 민감한
67
+ // 내용은 다 보인다. 그래서 기본값은 "내 계정으로 들어왔으면 타이핑도 허용" — 화면 앞에 사람이 없는
68
+ // 서버·도커에서는 애초에 승인할 방법도 없었다. 노트북처럼 한 단계 더 두고 싶은 경우에만
69
+ // --require-approval 로 예전처럼 매번 물어보게 할 수 있다.
70
+ const requireApproval = options.requireApproval === true;
50
71
  const primaryCwd = options.dir ? path.resolve(options.dir) : process.cwd();
51
72
  // 이름 앞부분(컴퓨터를 가리키는 부분) — 이 프로세스가 나중에 만드는 세션들이 물려받는다.
52
73
  // 도커 안에서는 os.hostname() 이 컨테이너 ID라서, --name 을 준 경우 그걸 쓰는 게 알아보기 좋음.
53
74
  const hostLabel = options.name ?? os.hostname();
75
+ // 이 컴퓨터를 가리키는 고정 식별자 — 세션이 여러 개여도 폰에서 한 컴퓨터로 묶어서 보여주기 위함
76
+ const hostId = loadOrCreateHostId();
54
77
  const shell = resolveShell();
55
78
  const sessions = new Map();
56
79
  function defaultName(cwd) {
@@ -76,16 +99,27 @@ export async function runAgent(options) {
76
99
  const chunk = session.outBuffer;
77
100
  session.outBuffer = '';
78
101
  session.lastSentAt = Date.now();
79
- await send({
80
- kind: 'output',
81
- to: session.deviceId,
82
- data: encryptFrame(chunk, session.frameKey),
83
- });
102
+ try {
103
+ await channel.send({
104
+ kind: 'output',
105
+ to: session.deviceId,
106
+ data: encryptFrame(chunk, session.frameKey),
107
+ }, { includeSelf: false });
108
+ }
109
+ catch (err) {
110
+ // 실패한 출력을 그냥 버리면 폰 화면이 깨진 채로 남으니, 다음 전송 앞에 다시 붙여 순서대로 재시도한다
111
+ session.outBuffer = chunk + session.outBuffer;
112
+ session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
113
+ console.error('출력 전송 실패, 다시 시도해요:', err.message);
114
+ if (!session.closing)
115
+ scheduleFlush(session);
116
+ }
84
117
  };
85
118
  const scheduleFlush = (session) => {
86
119
  if (session.pendingFlush !== null)
87
120
  return;
88
- const wait = Math.max(0, MIN_SEND_INTERVAL_MS - (Date.now() - session.lastSentAt));
121
+ const interval = Date.now() < session.sendPenaltyUntil ? PENALTY_INTERVAL_MS : MIN_SEND_INTERVAL_MS;
122
+ const wait = Math.max(0, interval - (Date.now() - session.lastSentAt));
89
123
  session.pendingFlush = setTimeout(() => void flush(session), wait);
90
124
  };
91
125
  // stdin 은 프로세스에 하나뿐이라, 세션이 여러 개일 때 승인 프롬프트가 겹치지 않게 한 번에 하나씩 묻는다
@@ -96,7 +130,7 @@ export async function runAgent(options) {
96
130
  .then(async () => {
97
131
  if (session.closing)
98
132
  return;
99
- const approved = autoApprove
133
+ const approved = !requireApproval
100
134
  ? true
101
135
  : await promptApproval(`\n📱 "${session.name}" 세션에 모바일 접속 요청이 왔어요. 입력을 허용할까요? (y/N, 30초 내 미응답 시 자동 거부): `, APPROVAL_TIMEOUT_MS);
102
136
  session.inputApproved = approved;
@@ -143,6 +177,7 @@ export async function runAgent(options) {
143
177
  approvalInFlight: false,
144
178
  outBuffer: '',
145
179
  lastSentAt: 0,
180
+ sendPenaltyUntil: 0,
146
181
  pendingFlush: null,
147
182
  closing: false,
148
183
  };
@@ -164,6 +199,8 @@ export async function runAgent(options) {
164
199
  ownerId: memberId,
165
200
  deviceId: session.deviceId,
166
201
  deviceName: session.name,
202
+ hostId,
203
+ hostName: hostLabel,
167
204
  cwd,
168
205
  frameKey: session.frameKey,
169
206
  favoriteDirs: touchRecentDir(cwd),
@@ -285,7 +322,7 @@ export async function runAgent(options) {
285
322
  const control = readControl(frame.data, session.frameKey);
286
323
  if (!control)
287
324
  return;
288
- const listing = listDirs(typeof control.dir === 'string' && control.dir ? control.dir : session.cwd);
325
+ const listing = listDirs(typeof control.dir === 'string' && control.dir ? control.dir : liveCwd(session));
289
326
  void send({
290
327
  kind: 'dirs',
291
328
  to: session.deviceId,
@@ -345,10 +382,10 @@ export async function runAgent(options) {
345
382
  const savedRecords = options.restore === false ? [] : loadOpenSessions();
346
383
  // ── 첫 세션(사용자가 실행한 그 폴더)
347
384
  const primary = await createSession(primaryCwd, options.name ?? defaultName(primaryCwd));
348
- console.log(`"${primary.name}" 세션을 시작했어요 (작업 폴더: ${primary.cwd})`);
349
- if (autoApprove) {
350
- console.log(`⚠️ 자동 승인이 켜져 있어요${accountAutoApprove ? ' (계정 설정)' : ' (--auto-approve)'} — 접속 요청을 묻지 않고 자동 승인해요. 내 계정에 들어올 수 있는 사람은 누구나 곧바로 입력할 수 있어요.`);
351
- }
385
+ console.log(`"${primary.name}" 세션을 시작했어요 (작업 폴더: ${primary.cwd}, 계정: ${me.nickname})`);
386
+ console.log(requireApproval
387
+ ? '접속할 때마다 화면에서 승인(y) 물어봐요. (--require-approval)'
388
+ : '내 계정으로 로그인한 폰에서는 바로 입력할 수 있어요. 매번 확인받고 싶으면 --require-approval 로 실행하세요.');
352
389
  // ── 지난번에 열려 있던 세션 복구 (컴퓨터·컨테이너 재시작 후 자동으로 원래 상태로)
353
390
  if (options.restore === false) {
354
391
  console.log('이전 세션 복구는 건너뛰어요 (--no-restore)');
package/dist/cli.js CHANGED
@@ -36,7 +36,8 @@ program
36
36
  .description('이 컴퓨터의 터미널을 열어서 폰 접속을 받을 수 있게 시작 (한 프로세스가 여러 세션을 관리해요)')
37
37
  .option('--dir <path>', '이 폴더에서 터미널을 시작해요 (기본: 현재 폴더)')
38
38
  .option('--name <name>', '폰에서 보여줄 이 기기의 이름 (기본: 컴퓨터 이름)')
39
- .option('--auto-approve', '폰에서 접속할 때마다 묻지 않고 자동으로 승인해요 (편하지만, 계정에 접속하는 누구나 곧바로 입력할 수 있게 되니 주의)')
39
+ .option('--require-approval', '폰에서 접속할 때마다 컴퓨터에서 y 승인해야 입력이 가능해요 (기본: 계정이면 바로 허용)')
40
+ .option('--auto-approve', '(옛 옵션 — 이제 기본 동작이라 넣지 않아도 같아요)')
40
41
  .option('--no-restore', '지난번에 열려 있던 세션들을 자동으로 다시 띄우지 않아요')
41
42
  .action(async (opts) => {
42
43
  await runAgent(opts);
package/dist/devices.js CHANGED
@@ -7,6 +7,9 @@ export async function registerDevice(cb, params) {
7
7
  owner_id: params.ownerId,
8
8
  device_id: params.deviceId,
9
9
  device_name: params.deviceName,
10
+ // 폰 목록에서 "컴퓨터 단위로 묶고 그 안에 세션"으로 보여주기 위한 정보 (host.ts 참고)
11
+ host_id: params.hostId,
12
+ host_name: params.hostName,
10
13
  status: 'online',
11
14
  cwd: params.cwd,
12
15
  frame_key: params.frameKey,
package/dist/host.js ADDED
@@ -0,0 +1,31 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { CONFIG_DIR } from './config.js';
6
+ // 세션은 여러 개라도 "어느 컴퓨터의 세션인지" 폰에서 묶어 보여주려면 컴퓨터를 가리키는 고정 식별자가 필요하다.
7
+ // os.hostname() 은 도커 컨테이너 안에서 컨테이너 ID로 나오고 컨테이너를 다시 만들면 바뀌어서 쓸 수 없으므로,
8
+ // 홈 폴더(~/.shellbase)에 한 번 만들어 계속 재사용한다 — 홈을 마운트하는 도커 구성에서도 그대로 유지됨.
9
+ const HOST_PATH = path.join(CONFIG_DIR, 'host.json');
10
+ export function loadOrCreateHostId() {
11
+ try {
12
+ const parsed = JSON.parse(fs.readFileSync(HOST_PATH, 'utf8'));
13
+ if (parsed && typeof parsed.id === 'string' && parsed.id)
14
+ return parsed.id;
15
+ }
16
+ catch {
17
+ // 없거나 깨졌으면 새로 만든다
18
+ }
19
+ const id = `host-${crypto.randomUUID()}`;
20
+ try {
21
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
22
+ fs.writeFileSync(HOST_PATH, JSON.stringify({ id, hostname: os.hostname() }, null, 2), {
23
+ mode: 0o600,
24
+ });
25
+ }
26
+ catch (err) {
27
+ // 저장에 실패해도 이번 실행에서는 이 값으로 동작 (다음 실행에 새로 발급될 뿐)
28
+ console.error('컴퓨터 식별자 저장 실패:', err.message);
29
+ }
30
+ return id;
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellbase",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {