shellbase 0.9.1 → 0.11.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 +15 -0
- package/dist/agent.js +66 -8
- package/dist/config.js +4 -1
- package/dist/devices.js +8 -0
- package/dist/push.js +51 -0
- package/package.json +1 -1
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,17 +12,23 @@ 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';
|
|
19
19
|
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
|
+
import { pushSessionDone } from './push.js';
|
|
22
23
|
import { IMAGE_CHUNK_TTL_MS, pruneImages, saveImage } from './images.js';
|
|
23
24
|
import { watchDoneNotes } from './notify.js';
|
|
24
25
|
import { hookAsked, hookState, installHook, rememberHookAsked } from './claude-hook.js';
|
|
25
26
|
const HEARTBEAT_MS = 20_000;
|
|
27
|
+
// 임시 터미널 정리 — 보는 사람도 없고(폰에서 아무 신호도 없고) 화면도 조용한 채로 이만큼 지나면 닫는다.
|
|
28
|
+
// 폰은 보고 있는 동안 10초마다 신호(ping)를 보내므로, 앱을 닫거나 다른 세션으로 넘어가면 신호가 끊긴다.
|
|
29
|
+
// 5분이나 두는 이유: 폰 화면이 잠기면 타이머가 멈춰서 신호도 잠깐 끊기기 때문 (잠깐 자리를 비운 것과 구분).
|
|
30
|
+
const TEMP_ORPHAN_MS = 5 * 60 * 1000;
|
|
31
|
+
const TEMP_SWEEP_MS = 60 * 1000;
|
|
26
32
|
// 올린 사진 정리 주기 — 보관 기한이 하루라, 오래 켜둔 컴퓨터에서도 반나절 안에 치워진다
|
|
27
33
|
const IMAGE_SWEEP_MS = 6 * 60 * 60 * 1000;
|
|
28
34
|
const APPROVAL_TIMEOUT_MS = 30_000;
|
|
@@ -157,7 +163,8 @@ export async function runAgent(options) {
|
|
|
157
163
|
}
|
|
158
164
|
function persistOpenSessions() {
|
|
159
165
|
saveOpenSessions([...sessions.values()]
|
|
160
|
-
|
|
166
|
+
// 임시 터미널은 재시작 후 되살리지 않는다 — 한 번 쓰고 버리는 것이므로
|
|
167
|
+
.filter((session) => !session.closing && !session.temporary)
|
|
161
168
|
.map((session) => ({ dir: session.cwd, name: session.name })));
|
|
162
169
|
}
|
|
163
170
|
const send = async (frame) => {
|
|
@@ -256,10 +263,11 @@ export async function runAgent(options) {
|
|
|
256
263
|
session.pendingFlush = setTimeout(() => void flush(session), wait);
|
|
257
264
|
};
|
|
258
265
|
// ── 작업 완료 알림 ──────────────────────────────────────────────────────────
|
|
259
|
-
// 폰에
|
|
266
|
+
// 폰에 세 가지 방법으로 알린다:
|
|
260
267
|
// ① 실시간 신호 — 앱을 켜두고 있으면 곧바로 뜬다
|
|
261
268
|
// ② 목록 row 의 done_at — 나중에 앱을 열어봐도 "그 사이에 끝났다" 는 표시가 남아 있다
|
|
262
|
-
//
|
|
269
|
+
// ③ 푸시 — 앱을 완전히 닫아둔 폰에도 알림이 뜬다 (이 제품의 핵심: 자리를 안 지켜도 된다)
|
|
270
|
+
// ①만 있으면 앱을 켜두고 지켜봐야 하고, ②만 있으면 직접 열어봐야 알 수 있다.
|
|
263
271
|
const announceDone = (session, why) => {
|
|
264
272
|
if (session.closing)
|
|
265
273
|
return;
|
|
@@ -271,6 +279,8 @@ export async function runAgent(options) {
|
|
|
271
279
|
// 목록에 못 남겨도 실시간 신호는 이미 갔다 — 세션 동작에는 영향 없음
|
|
272
280
|
});
|
|
273
281
|
}
|
|
282
|
+
// 발송은 서버 함수가 한다 (push.ts 설명 참고). 함수가 없는 계정에서도 조용히 넘어간다.
|
|
283
|
+
void pushSessionDone(cb, { deviceId: session.deviceId, name: session.name });
|
|
274
284
|
};
|
|
275
285
|
// 출력이 이어지는지 끊겼는지를 따라가며, 조용해지는 순간을 잡는다 (규칙은 위 상수 설명 참고)
|
|
276
286
|
const trackBusy = (session, bytes) => {
|
|
@@ -322,7 +332,8 @@ export async function runAgent(options) {
|
|
|
322
332
|
console.error('접속 승인 처리 실패:', err.message);
|
|
323
333
|
});
|
|
324
334
|
}
|
|
325
|
-
async function createSession(dir, explicitName) {
|
|
335
|
+
async function createSession(dir, explicitName, opts = {}) {
|
|
336
|
+
const temporary = opts.temporary === true;
|
|
326
337
|
const cwd = path.resolve(dir);
|
|
327
338
|
if (sessions.size >= MAX_SESSIONS) {
|
|
328
339
|
throw new Error(`세션은 한 번에 최대 ${MAX_SESSIONS}개까지 열 수 있어요.`);
|
|
@@ -341,7 +352,9 @@ export async function runAgent(options) {
|
|
|
341
352
|
const session = {
|
|
342
353
|
deviceId,
|
|
343
354
|
frameKey: generateFrameKey(),
|
|
344
|
-
|
|
355
|
+
// 임시 터미널은 이름만 봐도 알 수 있게 (임시) 를 붙인다 — 목록에는 안 뜨지만
|
|
356
|
+
// 그 터미널을 보고 있는 동안 상단 칩에는 이 이름이 그대로 보인다
|
|
357
|
+
name: explicitName ?? (temporary ? `${defaultName(cwd)} (임시)` : defaultName(cwd)),
|
|
345
358
|
cwd,
|
|
346
359
|
rowId: '',
|
|
347
360
|
pty: pty.spawn(shell, [], {
|
|
@@ -377,6 +390,8 @@ export async function runAgent(options) {
|
|
|
377
390
|
idleTimer: null,
|
|
378
391
|
announcedFor: 0,
|
|
379
392
|
hookSeen: false,
|
|
393
|
+
temporary,
|
|
394
|
+
viewerSeenAt: Date.now(),
|
|
380
395
|
};
|
|
381
396
|
session.screen.loadAddon(session.serializer);
|
|
382
397
|
session.pty.onData((data) => {
|
|
@@ -401,6 +416,7 @@ export async function runAgent(options) {
|
|
|
401
416
|
cwd,
|
|
402
417
|
frameKey: session.frameKey,
|
|
403
418
|
favoriteDirs: touchRecentDir(cwd),
|
|
419
|
+
temporary,
|
|
404
420
|
});
|
|
405
421
|
}
|
|
406
422
|
catch (err) {
|
|
@@ -454,6 +470,7 @@ export async function runAgent(options) {
|
|
|
454
470
|
shuttingDown = true;
|
|
455
471
|
clearInterval(heartbeat);
|
|
456
472
|
clearInterval(imageSweep);
|
|
473
|
+
clearInterval(tempSweep);
|
|
457
474
|
stopNotes();
|
|
458
475
|
for (const session of [...sessions.values()]) {
|
|
459
476
|
session.closing = true;
|
|
@@ -515,6 +532,8 @@ export async function runAgent(options) {
|
|
|
515
532
|
const session = sessions.get(frame.to);
|
|
516
533
|
if (!session || session.closing)
|
|
517
534
|
return;
|
|
535
|
+
// 폰에서 온 프레임 = 누군가 이 세션을 보고 있다는 뜻 (임시 터미널 정리 판단에 쓴다)
|
|
536
|
+
session.viewerSeenAt = Date.now();
|
|
518
537
|
if (frame.kind === 'connect_request') {
|
|
519
538
|
// 화면 보기는 승인 여부와 무관하게 항상 되므로, 승인을 묻기 전에 지금 화면부터 그대로 복원해준다
|
|
520
539
|
void sendRestore(session);
|
|
@@ -542,6 +561,27 @@ export async function runAgent(options) {
|
|
|
542
561
|
void closeSession(session, { deliberate: true });
|
|
543
562
|
return;
|
|
544
563
|
}
|
|
564
|
+
// 임시 터미널을 정식 세션으로 — 목록에 나타나고, 재시작해도 되살아나고, 스스로 정리되지 않는다
|
|
565
|
+
if (frame.kind === 'keep_session') {
|
|
566
|
+
if (!readControl(frame.data, session.frameKey))
|
|
567
|
+
return;
|
|
568
|
+
if (!session.temporary)
|
|
569
|
+
return;
|
|
570
|
+
const before = session.name;
|
|
571
|
+
session.temporary = false;
|
|
572
|
+
session.name = session.name.replace(/ \(임시\)$/, '');
|
|
573
|
+
keepDevice(cb, session.rowId, session.name)
|
|
574
|
+
.then(() => {
|
|
575
|
+
persistOpenSessions();
|
|
576
|
+
console.log(`📱 "${before}" 를 계속 쓰기로 바꿨어요 — 이제 목록에 남습니다.`);
|
|
577
|
+
})
|
|
578
|
+
.catch((err) => {
|
|
579
|
+
session.temporary = true;
|
|
580
|
+
session.name = before;
|
|
581
|
+
console.error('계속 쓰기 전환 실패:', err.message);
|
|
582
|
+
});
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
545
585
|
// 폰에서 세션 이름 바꾸기 — 컴퓨터를 가리키는 앞부분은 유지해서 목록의 컴퓨터 묶음이 깨지지 않게 한다
|
|
546
586
|
if (frame.kind === 'rename') {
|
|
547
587
|
const control = readControl(frame.data, session.frameKey);
|
|
@@ -757,10 +797,12 @@ export async function runAgent(options) {
|
|
|
757
797
|
if (!control || typeof control.dir !== 'string')
|
|
758
798
|
return;
|
|
759
799
|
const dir = control.dir;
|
|
800
|
+
// 폰의 「+ 터미널」 은 임시로, 「여기서 새 세션」 은 정식 세션으로 연다
|
|
801
|
+
const temporary = control.temporary === true;
|
|
760
802
|
void (async () => {
|
|
761
803
|
try {
|
|
762
|
-
const created = await createSession(dir);
|
|
763
|
-
console.log(`\n📱 폰 요청으로
|
|
804
|
+
const created = await createSession(dir, undefined, { temporary });
|
|
805
|
+
console.log(`\n📱 폰 요청으로 ${temporary ? '임시 ' : ''}세션을 열었어요: "${created.name}" (${created.cwd})`);
|
|
764
806
|
}
|
|
765
807
|
catch (err) {
|
|
766
808
|
const reason = err.message;
|
|
@@ -796,6 +838,22 @@ export async function runAgent(options) {
|
|
|
796
838
|
}
|
|
797
839
|
}
|
|
798
840
|
});
|
|
841
|
+
// 임시 터미널 청소 — 폰이 창을 닫거나 앱이 죽어서 "끄기" 요청이 못 온 경우를 위한 안전망.
|
|
842
|
+
// (평소에는 폰이 그 터미널을 떠나는 순간 바로 끄기 요청을 보낸다)
|
|
843
|
+
const tempSweep = setInterval(() => {
|
|
844
|
+
const now = Date.now();
|
|
845
|
+
for (const session of sessions.values()) {
|
|
846
|
+
if (!session.temporary || session.closing)
|
|
847
|
+
continue;
|
|
848
|
+
if (now - session.viewerSeenAt < TEMP_ORPHAN_MS)
|
|
849
|
+
continue;
|
|
850
|
+
// 아무도 안 봐도 뭔가 돌고 있으면(출력이 계속 나오면) 끝날 때까지 기다린다
|
|
851
|
+
if (session.lastOutputAt && now - session.lastOutputAt < TEMP_ORPHAN_MS)
|
|
852
|
+
continue;
|
|
853
|
+
console.log(`🧹 임시 터미널 "${session.name}" 을(를) 아무도 안 봐서 정리해요.`);
|
|
854
|
+
void closeSession(session, { deliberate: true });
|
|
855
|
+
}
|
|
856
|
+
}, TEMP_SWEEP_MS);
|
|
799
857
|
const heartbeat = setInterval(() => {
|
|
800
858
|
for (const session of sessions.values()) {
|
|
801
859
|
if (session.closing || !session.rowId)
|
package/dist/config.js
CHANGED
|
@@ -8,7 +8,10 @@ export const TERMINAL_CATEGORY = 'shellbase-terminal';
|
|
|
8
8
|
export const DEVICES_TABLE_ID = '01a009c7-a33d-79ca-9eeb-f9dc2d442c1b';
|
|
9
9
|
// CLI 구글 로그인 중계용 (google-auth.ts) — 짧게 살고 암호화된 채로만 저장됨.
|
|
10
10
|
export const CLI_AUTH_SESSIONS_TABLE_ID = '01a00aa0-802d-7730-88ba-b831b7405188';
|
|
11
|
-
export const
|
|
11
|
+
export const WEB_APP_URL = 'https://shellbase.web.connectbase.world';
|
|
12
|
+
export const WEB_LOGIN_URL = `${WEB_APP_URL}/login`;
|
|
13
|
+
// 앱을 닫아둔 폰에 알림을 보내주는 서버 함수 (push.ts). 발송은 서버만 할 수 있어서 이걸 부른다.
|
|
14
|
+
export const PUSH_FUNCTION_ID = process.env.SHELLBASE_PUSH_FUNCTION ?? 'shellbase-notify';
|
|
12
15
|
export const CONFIG_DIR = path.join(os.homedir(), '.shellbase');
|
|
13
16
|
export const CREDENTIALS_PATH = path.join(CONFIG_DIR, 'credentials.json');
|
|
14
17
|
// 폰이 보내는 제어 프레임(세션 끄기 / 새 세션 열기)에 함께 실어 보내는 확인 문자열.
|
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/dist/push.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { PUSH_FUNCTION_ID, WEB_APP_URL } from './config.js';
|
|
2
|
+
// "앱을 완전히 닫아둔 폰"에 알림을 보내는 길.
|
|
3
|
+
//
|
|
4
|
+
// 발송 자체는 서버(ConnectBase Function)만 할 수 있다 — 브라우저·에이전트가 쓰는 공개키로는
|
|
5
|
+
// 푸시 발송이 막혀 있기 때문(권한이 새는 걸 막으려고 SDK 가 명시적으로 차단한다).
|
|
6
|
+
// 그래서 에이전트는 "끝났다"고 함수를 부르기만 하고, 누구에게 보낼지는 서버가 호출자 신원
|
|
7
|
+
// (ctx.memberId)으로 판단한다. 즉 남의 폰으로는 보낼 수 없다.
|
|
8
|
+
//
|
|
9
|
+
// 함수가 아직 배포 안 된 계정에서도 에이전트는 멀쩡히 돌아야 하므로, 실패는 조용히 넘긴다.
|
|
10
|
+
// 함수가 아직 배포 안 됐으면 매번 찔러봐야 낭비다. 그렇다고 영영 포기하면, 나중에 함수를
|
|
11
|
+
// 배포해도 에이전트를 껐다 켜기 전까지 알림이 안 온다 — 그래서 "한동안 쉬었다 다시 해본다".
|
|
12
|
+
let quietUntil = 0;
|
|
13
|
+
let consecutiveFailures = 0;
|
|
14
|
+
const GIVE_UP_AFTER = 5;
|
|
15
|
+
const RETRY_AFTER_MS = 10 * 60 * 1000;
|
|
16
|
+
function looksMissing(message) {
|
|
17
|
+
const lowered = message.toLowerCase();
|
|
18
|
+
return (lowered.includes('invalid function') ||
|
|
19
|
+
lowered.includes('not found') ||
|
|
20
|
+
lowered.includes('404') ||
|
|
21
|
+
lowered.includes('does not exist'));
|
|
22
|
+
}
|
|
23
|
+
export async function pushSessionDone(cb, params) {
|
|
24
|
+
if (Date.now() < quietUntil)
|
|
25
|
+
return;
|
|
26
|
+
if (consecutiveFailures >= GIVE_UP_AFTER) {
|
|
27
|
+
// 연달아 실패하는 중 — 잠깐 쉬었다가 처음부터 다시 세어본다
|
|
28
|
+
quietUntil = Date.now() + RETRY_AFTER_MS;
|
|
29
|
+
consecutiveFailures = 0;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
await cb.functions.invoke(PUSH_FUNCTION_ID, {
|
|
34
|
+
kind: 'session_done',
|
|
35
|
+
device_id: params.deviceId,
|
|
36
|
+
name: params.name,
|
|
37
|
+
url: `${WEB_APP_URL}/session/${params.deviceId}`,
|
|
38
|
+
});
|
|
39
|
+
consecutiveFailures = 0;
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
const message = err.message ?? '';
|
|
43
|
+
if (looksMissing(message)) {
|
|
44
|
+
// 아직 함수를 안 만든 계정 — 10분 뒤에 한 번 더 해본다 (그 사이 배포했을 수 있으므로)
|
|
45
|
+
quietUntil = Date.now() + RETRY_AFTER_MS;
|
|
46
|
+
consecutiveFailures = 0;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
consecutiveFailures += 1;
|
|
50
|
+
}
|
|
51
|
+
}
|