shellbase 0.9.1 → 0.10.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
@@ -117,6 +117,7 @@ shellbase start --no-restore # 복구하지 않고 이 폴더 세션 하나
117
117
  | 하고 싶은 것 | 폰에서 하는 법 |
118
118
  |---|---|
119
119
  | 세션 하나 더 열기 | 목록 화면의 **`+ 새 세션`**, 또는 터미널 화면의 기기 이름(▾) → **`+ 이 컴퓨터에서 새 세션 열기`** (열리면 그 세션으로 바로 넘어가요) |
120
+ | 잠깐 쓸 터미널 열기 | 터미널 화면 위쪽의 **`+ 임시 터미널`** — 지금 폴더에 하나 더 띄워요 (목록에 안 남고, 나가면 사라져요) |
120
121
  | 세션 갈아타기 | 터미널 화면 위쪽의 **기기 이름(▾)** 탭 → 원하는 세션 선택 |
121
122
  | 세션 끄기 | 목록의 **전원 아이콘**, 또는 터미널 화면의 기기 이름(▾) → **이 세션 끄기** |
122
123
  | 화면만 닫기 | `← 목록` 으로 나가기 — 컴퓨터의 세션은 계속 살아있어요 |
@@ -132,6 +133,20 @@ shellbase start --no-restore # 복구하지 않고 이 폴더 세션 하나
132
133
  종료돼요(그 대신 다음 실행 때 자동 복구됩니다). 세션을 하나만 끄고 싶으면 폰에서 그 세션의 `✕` 를
133
134
  쓰세요 — 나머지 세션은 그대로 유지돼요.
134
135
 
136
+ ### 임시 터미널 (0.10.0+)
137
+
138
+ Claude Code 가 화면을 꽉 채우고 있을 때 `git status` 같은 걸 잠깐 쳐보려고 터미널을 하나 더 열곤 하는데,
139
+ 그렇게 연 터미널이 목록에 계속 쌓이는 게 문제였어요. 터미널 화면 위쪽의 **`+ 임시 터미널`** 로 연 것은
140
+ **한 번 쓰고 버리는** 터미널이에요.
141
+
142
+ - 세션 목록에도, 상단 칩 줄에도 **안 보여요** (지금 보고 있는 동안만 칩으로 보입니다)
143
+ - 그 화면에서 나가면 **스스로 종료**돼요 — 다만 **방금까지 뭔가 돌고 있었으면 끄지 않고 목록에 남깁니다**
144
+ (하던 일이 조용히 사라지면 안 되니까요)
145
+ - 컴퓨터를 재시작해도 **되살아나지 않아요** (정식 세션만 복구 대상)
146
+ - 브라우저를 그냥 닫아버린 경우엔, 아무도 안 보고 화면도 조용한 채로 **5분**이 지나면 컴퓨터가 알아서 정리해요
147
+ - 계속 쓰고 싶어지면 **`임시 · 계속 쓰기`** 를 누르세요 — 정식 세션이 되고 이름의 `(임시)` 표시도 떨어져요
148
+ - 임시 터미널에는 **Claude 자동 시작이 적용되지 않아요** (명령 하나 치려고 여는 것이라 빈 터미널로 열립니다)
149
+
135
150
  ### 파일 보기·편집
136
151
 
137
152
  `📁 폴더 찾기` 목록에는 폴더와 함께 **파일**도 나와요. 파일을 탭하면 편집기가 열리고, 고친 뒤 `저장` 을
package/dist/agent.js CHANGED
@@ -12,7 +12,7 @@ import { requireClient } from './auth.js';
12
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, markSessionDone, } from './devices.js';
15
+ import { registerDevice, heartbeatDevice, unregisterDevice, listOnlineNames, renameDevice, markSessionDone, keepDevice, } 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';
@@ -23,6 +23,11 @@ import { IMAGE_CHUNK_TTL_MS, pruneImages, saveImage } from './images.js';
23
23
  import { watchDoneNotes } from './notify.js';
24
24
  import { hookAsked, hookState, installHook, rememberHookAsked } from './claude-hook.js';
25
25
  const HEARTBEAT_MS = 20_000;
26
+ // 임시 터미널 정리 — 보는 사람도 없고(폰에서 아무 신호도 없고) 화면도 조용한 채로 이만큼 지나면 닫는다.
27
+ // 폰은 보고 있는 동안 10초마다 신호(ping)를 보내므로, 앱을 닫거나 다른 세션으로 넘어가면 신호가 끊긴다.
28
+ // 5분이나 두는 이유: 폰 화면이 잠기면 타이머가 멈춰서 신호도 잠깐 끊기기 때문 (잠깐 자리를 비운 것과 구분).
29
+ const TEMP_ORPHAN_MS = 5 * 60 * 1000;
30
+ const TEMP_SWEEP_MS = 60 * 1000;
26
31
  // 올린 사진 정리 주기 — 보관 기한이 하루라, 오래 켜둔 컴퓨터에서도 반나절 안에 치워진다
27
32
  const IMAGE_SWEEP_MS = 6 * 60 * 60 * 1000;
28
33
  const APPROVAL_TIMEOUT_MS = 30_000;
@@ -157,7 +162,8 @@ export async function runAgent(options) {
157
162
  }
158
163
  function persistOpenSessions() {
159
164
  saveOpenSessions([...sessions.values()]
160
- .filter((session) => !session.closing)
165
+ // 임시 터미널은 재시작 후 되살리지 않는다 — 한 번 쓰고 버리는 것이므로
166
+ .filter((session) => !session.closing && !session.temporary)
161
167
  .map((session) => ({ dir: session.cwd, name: session.name })));
162
168
  }
163
169
  const send = async (frame) => {
@@ -322,7 +328,8 @@ export async function runAgent(options) {
322
328
  console.error('접속 승인 처리 실패:', err.message);
323
329
  });
324
330
  }
325
- async function createSession(dir, explicitName) {
331
+ async function createSession(dir, explicitName, opts = {}) {
332
+ const temporary = opts.temporary === true;
326
333
  const cwd = path.resolve(dir);
327
334
  if (sessions.size >= MAX_SESSIONS) {
328
335
  throw new Error(`세션은 한 번에 최대 ${MAX_SESSIONS}개까지 열 수 있어요.`);
@@ -341,7 +348,9 @@ export async function runAgent(options) {
341
348
  const session = {
342
349
  deviceId,
343
350
  frameKey: generateFrameKey(),
344
- name: explicitName ?? defaultName(cwd),
351
+ // 임시 터미널은 이름만 봐도 알 수 있게 (임시) 를 붙인다 — 목록에는 안 뜨지만
352
+ // 그 터미널을 보고 있는 동안 상단 칩에는 이 이름이 그대로 보인다
353
+ name: explicitName ?? (temporary ? `${defaultName(cwd)} (임시)` : defaultName(cwd)),
345
354
  cwd,
346
355
  rowId: '',
347
356
  pty: pty.spawn(shell, [], {
@@ -377,6 +386,8 @@ export async function runAgent(options) {
377
386
  idleTimer: null,
378
387
  announcedFor: 0,
379
388
  hookSeen: false,
389
+ temporary,
390
+ viewerSeenAt: Date.now(),
380
391
  };
381
392
  session.screen.loadAddon(session.serializer);
382
393
  session.pty.onData((data) => {
@@ -401,6 +412,7 @@ export async function runAgent(options) {
401
412
  cwd,
402
413
  frameKey: session.frameKey,
403
414
  favoriteDirs: touchRecentDir(cwd),
415
+ temporary,
404
416
  });
405
417
  }
406
418
  catch (err) {
@@ -454,6 +466,7 @@ export async function runAgent(options) {
454
466
  shuttingDown = true;
455
467
  clearInterval(heartbeat);
456
468
  clearInterval(imageSweep);
469
+ clearInterval(tempSweep);
457
470
  stopNotes();
458
471
  for (const session of [...sessions.values()]) {
459
472
  session.closing = true;
@@ -515,6 +528,8 @@ export async function runAgent(options) {
515
528
  const session = sessions.get(frame.to);
516
529
  if (!session || session.closing)
517
530
  return;
531
+ // 폰에서 온 프레임 = 누군가 이 세션을 보고 있다는 뜻 (임시 터미널 정리 판단에 쓴다)
532
+ session.viewerSeenAt = Date.now();
518
533
  if (frame.kind === 'connect_request') {
519
534
  // 화면 보기는 승인 여부와 무관하게 항상 되므로, 승인을 묻기 전에 지금 화면부터 그대로 복원해준다
520
535
  void sendRestore(session);
@@ -542,6 +557,27 @@ export async function runAgent(options) {
542
557
  void closeSession(session, { deliberate: true });
543
558
  return;
544
559
  }
560
+ // 임시 터미널을 정식 세션으로 — 목록에 나타나고, 재시작해도 되살아나고, 스스로 정리되지 않는다
561
+ if (frame.kind === 'keep_session') {
562
+ if (!readControl(frame.data, session.frameKey))
563
+ return;
564
+ if (!session.temporary)
565
+ return;
566
+ const before = session.name;
567
+ session.temporary = false;
568
+ session.name = session.name.replace(/ \(임시\)$/, '');
569
+ keepDevice(cb, session.rowId, session.name)
570
+ .then(() => {
571
+ persistOpenSessions();
572
+ console.log(`📱 "${before}" 를 계속 쓰기로 바꿨어요 — 이제 목록에 남습니다.`);
573
+ })
574
+ .catch((err) => {
575
+ session.temporary = true;
576
+ session.name = before;
577
+ console.error('계속 쓰기 전환 실패:', err.message);
578
+ });
579
+ return;
580
+ }
545
581
  // 폰에서 세션 이름 바꾸기 — 컴퓨터를 가리키는 앞부분은 유지해서 목록의 컴퓨터 묶음이 깨지지 않게 한다
546
582
  if (frame.kind === 'rename') {
547
583
  const control = readControl(frame.data, session.frameKey);
@@ -757,10 +793,12 @@ export async function runAgent(options) {
757
793
  if (!control || typeof control.dir !== 'string')
758
794
  return;
759
795
  const dir = control.dir;
796
+ // 폰의 「+ 터미널」 은 임시로, 「여기서 새 세션」 은 정식 세션으로 연다
797
+ const temporary = control.temporary === true;
760
798
  void (async () => {
761
799
  try {
762
- const created = await createSession(dir);
763
- console.log(`\n📱 폰 요청으로 세션을 열었어요: "${created.name}" (${created.cwd})`);
800
+ const created = await createSession(dir, undefined, { temporary });
801
+ console.log(`\n📱 폰 요청으로 ${temporary ? '임시 ' : ''}세션을 열었어요: "${created.name}" (${created.cwd})`);
764
802
  }
765
803
  catch (err) {
766
804
  const reason = err.message;
@@ -796,6 +834,22 @@ export async function runAgent(options) {
796
834
  }
797
835
  }
798
836
  });
837
+ // 임시 터미널 청소 — 폰이 창을 닫거나 앱이 죽어서 "끄기" 요청이 못 온 경우를 위한 안전망.
838
+ // (평소에는 폰이 그 터미널을 떠나는 순간 바로 끄기 요청을 보낸다)
839
+ const tempSweep = setInterval(() => {
840
+ const now = Date.now();
841
+ for (const session of sessions.values()) {
842
+ if (!session.temporary || session.closing)
843
+ continue;
844
+ if (now - session.viewerSeenAt < TEMP_ORPHAN_MS)
845
+ continue;
846
+ // 아무도 안 봐도 뭔가 돌고 있으면(출력이 계속 나오면) 끝날 때까지 기다린다
847
+ if (session.lastOutputAt && now - session.lastOutputAt < TEMP_ORPHAN_MS)
848
+ continue;
849
+ console.log(`🧹 임시 터미널 "${session.name}" 을(를) 아무도 안 봐서 정리해요.`);
850
+ void closeSession(session, { deliberate: true });
851
+ }
852
+ }, TEMP_SWEEP_MS);
799
853
  const heartbeat = setInterval(() => {
800
854
  for (const session of sessions.values()) {
801
855
  if (session.closing || !session.rowId)
package/dist/devices.js CHANGED
@@ -11,6 +11,7 @@ export async function registerDevice(cb, params) {
11
11
  cwd: params.cwd,
12
12
  frame_key: params.frameKey,
13
13
  favorite_dirs: params.favoriteDirs,
14
+ temporary: params.temporary === true,
14
15
  last_seen_at: new Date().toISOString(),
15
16
  },
16
17
  });
@@ -47,6 +48,13 @@ export async function listOnlineNames(cb) {
47
48
  }
48
49
  return names;
49
50
  }
51
+ // 임시 터미널을 "계속 쓰기" 로 바꿀 때 — 목록에 정식으로 남기고 이름에서 (임시) 표시를 뗀다.
52
+ // 반대 방향(정식 → 임시)은 없다: 이미 쓰고 있던 터미널이 조용히 사라지는 건 사고이기 때문.
53
+ export async function keepDevice(cb, rowId, deviceName) {
54
+ await cb.database.updateData(DEVICES_TABLE_ID, rowId, {
55
+ data: { temporary: false, device_name: deviceName },
56
+ });
57
+ }
50
58
  // 폰에서 세션 이름을 바꿀 때 — 목록 row 의 표시 이름만 갱신한다 (세션 자체는 그대로 유지)
51
59
  export async function renameDevice(cb, rowId, deviceName) {
52
60
  await cb.database.updateData(DEVICES_TABLE_ID, rowId, { data: { device_name: deviceName } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellbase",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {