shellbase 0.9.0 → 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) => {
@@ -168,6 +174,13 @@ export async function runAgent(options) {
168
174
  console.error('전송 실패:', err.message);
169
175
  }
170
176
  };
177
+ // 이 세션이 보내는 것들을 한 줄로 세운다 — 앞의 것이 끝나야 다음 것이 나간다.
178
+ const enqueueSend = (session, task) => {
179
+ const next = session.sendChain.then(task, task);
180
+ // 하나가 실패해도 줄 전체가 멈추면 안 되므로, 줄에는 성공/실패를 흡수한 것만 남긴다
181
+ session.sendChain = next.then(() => undefined, () => undefined);
182
+ return next;
183
+ };
171
184
  const flush = async (session) => {
172
185
  session.pendingFlush = null;
173
186
  if (!session.outBuffer)
@@ -175,20 +188,70 @@ export async function runAgent(options) {
175
188
  const chunk = session.outBuffer;
176
189
  session.outBuffer = '';
177
190
  session.lastSentAt = Date.now();
191
+ await enqueueSend(session, async () => {
192
+ try {
193
+ await channel.send({
194
+ kind: 'output',
195
+ to: session.deviceId,
196
+ data: encryptFrame(chunk, session.frameKey),
197
+ }, { includeSelf: false });
198
+ }
199
+ catch (err) {
200
+ // 실패한 출력을 그냥 버리면 폰 화면이 깨진 채로 남으니, 다음 전송 앞에 다시 붙여 순서대로 재시도한다
201
+ session.outBuffer = chunk + session.outBuffer;
202
+ session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
203
+ console.error('출력 전송 실패, 다시 시도해요:', err.message);
204
+ if (!session.closing)
205
+ scheduleFlush(session);
206
+ }
207
+ });
208
+ };
209
+ // 폰이 "화면을 통째로 다시 달라"고 할 때 (처음 접속·재연결·크기 변경 뒤).
210
+ const sendRestore = async (session) => {
211
+ if (session.restoring) {
212
+ // 보내는 중에 또 요청이 왔다 — 지금 것이 끝난 뒤 한 번만 더 보낸다.
213
+ // 두 벌을 동시에 보내면 조각이 서로 끼어들어 폰 화면에 같은 내용이 두 번 그려진다.
214
+ session.restoreAgain = true;
215
+ return;
216
+ }
217
+ session.restoring = true;
178
218
  try {
179
- await channel.send({
180
- kind: 'output',
181
- to: session.deviceId,
182
- data: encryptFrame(chunk, session.frameKey),
183
- }, { includeSelf: false });
219
+ do {
220
+ session.restoreAgain = false;
221
+ if (session.closing)
222
+ return;
223
+ // 지금 화면 상태를 통째로 보낼 참이다. 아직 못 보낸 출력(outBuffer)은 이미 그 화면 안에
224
+ // 들어 있다 — pty.onData 가 outBuffer 와 screen 에 같이 넣기 때문. 그대로 두면 복원 직후
225
+ // 같은 내용이 한 번 더 나가서 화면에 두 번 찍힌다. 여기서 버린다.
226
+ // (아래 await 전까지는 끊기지 않고 실행되므로, 버리는 것과 찍어내는 것 사이에 새 출력이
227
+ // 끼어들 틈이 없다)
228
+ if (session.pendingFlush !== null) {
229
+ clearTimeout(session.pendingFlush);
230
+ session.pendingFlush = null;
231
+ }
232
+ session.outBuffer = '';
233
+ let snapshot;
234
+ try {
235
+ snapshot = session.serializer.serialize({ scrollback: RESTORE_SCROLLBACK_LINES });
236
+ }
237
+ catch (err) {
238
+ console.error('화면 복원 준비 실패:', err.message);
239
+ return;
240
+ }
241
+ // 화면을 먼저 지우고(2J·3J) 복원 내용을 보내야, 폰에 남아있던 예전 화면과 섞이지 않는다
242
+ const payload = `\x1b[H\x1b[2J\x1b[3J${snapshot}`;
243
+ for (let i = 0; i < payload.length; i += RESTORE_CHUNK) {
244
+ const part = payload.slice(i, i + RESTORE_CHUNK);
245
+ await enqueueSend(session, () => send({
246
+ kind: 'output',
247
+ to: session.deviceId,
248
+ data: encryptFrame(part, session.frameKey),
249
+ }));
250
+ }
251
+ } while (session.restoreAgain);
184
252
  }
185
- catch (err) {
186
- // 실패한 출력을 그냥 버리면 폰 화면이 깨진 채로 남으니, 다음 전송 앞에 다시 붙여 순서대로 재시도한다
187
- session.outBuffer = chunk + session.outBuffer;
188
- session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
189
- console.error('출력 전송 실패, 다시 시도해요:', err.message);
190
- if (!session.closing)
191
- scheduleFlush(session);
253
+ finally {
254
+ session.restoring = false;
192
255
  }
193
256
  };
194
257
  const scheduleFlush = (session) => {
@@ -265,7 +328,8 @@ export async function runAgent(options) {
265
328
  console.error('접속 승인 처리 실패:', err.message);
266
329
  });
267
330
  }
268
- async function createSession(dir, explicitName) {
331
+ async function createSession(dir, explicitName, opts = {}) {
332
+ const temporary = opts.temporary === true;
269
333
  const cwd = path.resolve(dir);
270
334
  if (sessions.size >= MAX_SESSIONS) {
271
335
  throw new Error(`세션은 한 번에 최대 ${MAX_SESSIONS}개까지 열 수 있어요.`);
@@ -284,7 +348,9 @@ export async function runAgent(options) {
284
348
  const session = {
285
349
  deviceId,
286
350
  frameKey: generateFrameKey(),
287
- name: explicitName ?? defaultName(cwd),
351
+ // 임시 터미널은 이름만 봐도 알 수 있게 (임시) 를 붙인다 — 목록에는 안 뜨지만
352
+ // 그 터미널을 보고 있는 동안 상단 칩에는 이 이름이 그대로 보인다
353
+ name: explicitName ?? (temporary ? `${defaultName(cwd)} (임시)` : defaultName(cwd)),
288
354
  cwd,
289
355
  rowId: '',
290
356
  pty: pty.spawn(shell, [], {
@@ -307,6 +373,9 @@ export async function runAgent(options) {
307
373
  inputApproved: false,
308
374
  approvalInFlight: false,
309
375
  outBuffer: '',
376
+ sendChain: Promise.resolve(),
377
+ restoring: false,
378
+ restoreAgain: false,
310
379
  lastSentAt: 0,
311
380
  sendPenaltyUntil: 0,
312
381
  pendingFlush: null,
@@ -317,6 +386,8 @@ export async function runAgent(options) {
317
386
  idleTimer: null,
318
387
  announcedFor: 0,
319
388
  hookSeen: false,
389
+ temporary,
390
+ viewerSeenAt: Date.now(),
320
391
  };
321
392
  session.screen.loadAddon(session.serializer);
322
393
  session.pty.onData((data) => {
@@ -341,6 +412,7 @@ export async function runAgent(options) {
341
412
  cwd,
342
413
  frameKey: session.frameKey,
343
414
  favoriteDirs: touchRecentDir(cwd),
415
+ temporary,
344
416
  });
345
417
  }
346
418
  catch (err) {
@@ -394,6 +466,7 @@ export async function runAgent(options) {
394
466
  shuttingDown = true;
395
467
  clearInterval(heartbeat);
396
468
  clearInterval(imageSweep);
469
+ clearInterval(tempSweep);
397
470
  stopNotes();
398
471
  for (const session of [...sessions.values()]) {
399
472
  session.closing = true;
@@ -455,27 +528,11 @@ export async function runAgent(options) {
455
528
  const session = sessions.get(frame.to);
456
529
  if (!session || session.closing)
457
530
  return;
531
+ // 폰에서 온 프레임 = 누군가 이 세션을 보고 있다는 뜻 (임시 터미널 정리 판단에 쓴다)
532
+ session.viewerSeenAt = Date.now();
458
533
  if (frame.kind === 'connect_request') {
459
- // 화면 보기는 승인 여부와 무관하게 항상 되므로, 승인을 묻기 전에 지금 화면부터 그대로 복원해준다.
460
- // 화면을 먼저 지우고(2J·3J) 복원 내용을 보내야, 폰에 남아있던 예전 화면과 섞이지 않는다.
461
- void (async () => {
462
- let snapshot;
463
- try {
464
- snapshot = session.serializer.serialize({ scrollback: RESTORE_SCROLLBACK_LINES });
465
- }
466
- catch (err) {
467
- console.error('화면 복원 준비 실패:', err.message);
468
- return;
469
- }
470
- const payload = `\x1b[H\x1b[2J\x1b[3J${snapshot}`;
471
- for (let i = 0; i < payload.length; i += RESTORE_CHUNK) {
472
- await send({
473
- kind: 'output',
474
- to: session.deviceId,
475
- data: encryptFrame(payload.slice(i, i + RESTORE_CHUNK), session.frameKey),
476
- });
477
- }
478
- })();
534
+ // 화면 보기는 승인 여부와 무관하게 항상 되므로, 승인을 묻기 전에 지금 화면부터 그대로 복원해준다
535
+ void sendRestore(session);
479
536
  // 승인을 묻지 않는 기본 설정에서는 요청이 올 때마다 곧바로 답한다.
480
537
  // (예전엔 "승인 진행 중"이면 건너뛰었는데, 그 사이 온 요청은 영영 답을 못 받아서
481
538
  // 폰이 "승인 대기 중" 화면에 갇히는 일이 생겼음 — 자동 승인은 기다릴 게 없으므로 항상 응답)
@@ -500,6 +557,27 @@ export async function runAgent(options) {
500
557
  void closeSession(session, { deliberate: true });
501
558
  return;
502
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
+ }
503
581
  // 폰에서 세션 이름 바꾸기 — 컴퓨터를 가리키는 앞부분은 유지해서 목록의 컴퓨터 묶음이 깨지지 않게 한다
504
582
  if (frame.kind === 'rename') {
505
583
  const control = readControl(frame.data, session.frameKey);
@@ -715,10 +793,12 @@ export async function runAgent(options) {
715
793
  if (!control || typeof control.dir !== 'string')
716
794
  return;
717
795
  const dir = control.dir;
796
+ // 폰의 「+ 터미널」 은 임시로, 「여기서 새 세션」 은 정식 세션으로 연다
797
+ const temporary = control.temporary === true;
718
798
  void (async () => {
719
799
  try {
720
- const created = await createSession(dir);
721
- console.log(`\n📱 폰 요청으로 세션을 열었어요: "${created.name}" (${created.cwd})`);
800
+ const created = await createSession(dir, undefined, { temporary });
801
+ console.log(`\n📱 폰 요청으로 ${temporary ? '임시 ' : ''}세션을 열었어요: "${created.name}" (${created.cwd})`);
722
802
  }
723
803
  catch (err) {
724
804
  const reason = err.message;
@@ -754,6 +834,22 @@ export async function runAgent(options) {
754
834
  }
755
835
  }
756
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);
757
853
  const heartbeat = setInterval(() => {
758
854
  for (const session of sessions.values()) {
759
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.0",
3
+ "version": "0.10.0",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {