shellbase 0.10.0 → 0.12.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/dist/agent.js CHANGED
@@ -15,10 +15,11 @@ import { encryptFrame, decryptFrame, generateFrameKey } from './crypto.js';
15
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
- import { listDirs } from './browse.js';
18
+ import { listDirs, deleteEntry, renameEntry } 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';
@@ -262,10 +263,11 @@ export async function runAgent(options) {
262
263
  session.pendingFlush = setTimeout(() => void flush(session), wait);
263
264
  };
264
265
  // ── 작업 완료 알림 ──────────────────────────────────────────────────────────
265
- // 폰에 가지 방법으로 알린다:
266
+ // 폰에 가지 방법으로 알린다:
266
267
  // ① 실시간 신호 — 앱을 켜두고 있으면 곧바로 뜬다
267
268
  // ② 목록 row 의 done_at — 나중에 앱을 열어봐도 "그 사이에 끝났다" 는 표시가 남아 있다
268
- // ②가 없으면 폰이 꺼져 있던 동안 끝난 작업을 영영 모르게 되므로 남긴다.
269
+ // 푸시 앱을 완전히 닫아둔 폰에도 알림이 뜬다 (이 제품의 핵심: 자리를 지켜도 된다)
270
+ // ①만 있으면 앱을 켜두고 지켜봐야 하고, ②만 있으면 직접 열어봐야 알 수 있다.
269
271
  const announceDone = (session, why) => {
270
272
  if (session.closing)
271
273
  return;
@@ -277,6 +279,8 @@ export async function runAgent(options) {
277
279
  // 목록에 못 남겨도 실시간 신호는 이미 갔다 — 세션 동작에는 영향 없음
278
280
  });
279
281
  }
282
+ // 발송은 서버 함수가 한다 (push.ts 설명 참고). 함수가 없는 계정에서도 조용히 넘어간다.
283
+ void pushSessionDone(cb, { deviceId: session.deviceId, name: session.name });
280
284
  };
281
285
  // 출력이 이어지는지 끊겼는지를 따라가며, 조용해지는 순간을 잡는다 (규칙은 위 상수 설명 참고)
282
286
  const trackBusy = (session, bytes) => {
@@ -780,7 +784,7 @@ export async function runAgent(options) {
780
784
  const control = readControl(frame.data, session.frameKey);
781
785
  if (!control)
782
786
  return;
783
- const listing = listDirs(typeof control.dir === 'string' && control.dir ? control.dir : liveCwd(session));
787
+ const listing = listDirs(typeof control.dir === 'string' && control.dir ? control.dir : liveCwd(session), { hidden: control.hidden === true });
784
788
  void send({
785
789
  kind: 'dirs',
786
790
  to: session.deviceId,
@@ -788,6 +792,37 @@ export async function runAgent(options) {
788
792
  });
789
793
  return;
790
794
  }
795
+ // 폴더 찾기에서 이름 바꾸기 / 지우기. 어느 쪽이든 끝나면 **새 목록**을 돌려줘서
796
+ // 폰 화면이 곧바로 최신 상태가 되게 한다 (실패 사유도 그 목록에 실어 보낸다).
797
+ if (frame.kind === 'path_action') {
798
+ const control = readControl(frame.data, session.frameKey);
799
+ if (!control)
800
+ return;
801
+ const dir = typeof control.dir === 'string' && control.dir ? control.dir : liveCwd(session);
802
+ const name = typeof control.name === 'string' ? control.name : '';
803
+ let failure;
804
+ if (control.op === 'rename') {
805
+ const to = typeof control.to === 'string' ? control.to : '';
806
+ failure = renameEntry(dir, name, to).error;
807
+ if (!failure)
808
+ console.log(`📱 폰에서 이름을 바꿨어요: ${name} → ${to} (${dir})`);
809
+ }
810
+ else if (control.op === 'delete') {
811
+ failure = deleteEntry(dir, name).error;
812
+ if (!failure)
813
+ console.log(`📱 폰에서 지웠어요: ${name} (${dir})`);
814
+ }
815
+ else {
816
+ failure = '알 수 없는 요청이에요.';
817
+ }
818
+ const listing = listDirs(dir, { hidden: control.hidden === true });
819
+ void send({
820
+ kind: 'dirs',
821
+ to: session.deviceId,
822
+ data: encryptFrame(JSON.stringify(failure ? { ...listing, error: failure } : listing), session.frameKey),
823
+ });
824
+ return;
825
+ }
791
826
  if (frame.kind === 'new_session') {
792
827
  const control = readControl(frame.data, session.frameKey);
793
828
  if (!control || typeof control.dir !== 'string')
package/dist/browse.js CHANGED
@@ -4,7 +4,69 @@ import path from 'node:path';
4
4
  // 폰에서 폴더를 탭해서 파고들며 고를 수 있게, 요청받은 경로의 "하위 폴더 목록"만 돌려준다.
5
5
  // 파일 이름은 보내지 않음 — 세션을 열 위치를 고르는 용도이고, 목록이 길어질 이유도 없음.
6
6
  const MAX_ENTRIES = 300;
7
- export function listDirs(dir) {
7
+ // 폴더 안의 항목 하나를 가리키는 안전한 경로를 만든다.
8
+ // 이름에 `/` 나 `..` 이 섞여 들어오면 폴더 밖을 건드릴 수 있으므로 여기서 전부 막는다
9
+ // (폰에서 오는 값이라 반드시 이 컴퓨터 쪽에서 검사해야 한다).
10
+ function childPath(dir, name) {
11
+ if (!name || name === '.' || name === '..')
12
+ return null;
13
+ if (name.includes('/') || name.includes('\\'))
14
+ return null;
15
+ const parent = path.resolve(dir);
16
+ const target = path.resolve(parent, name);
17
+ if (path.dirname(target) !== parent)
18
+ return null;
19
+ return target;
20
+ }
21
+ // 이름 바꾸기 — 같은 폴더 안에서만, 이미 있는 이름으로는 덮어쓰지 않는다
22
+ export function renameEntry(dir, name, to) {
23
+ const from = childPath(dir, name);
24
+ const dest = childPath(dir, to);
25
+ if (!from || !dest)
26
+ return { error: '이름에 쓸 수 없는 글자가 있어요.' };
27
+ if (from === dest)
28
+ return {};
29
+ if (fs.existsSync(dest))
30
+ return { error: `"${to}" 는 이미 있어요.` };
31
+ try {
32
+ fs.renameSync(from, dest);
33
+ return {};
34
+ }
35
+ catch (err) {
36
+ return { error: `이름을 바꾸지 못했어요: ${err.message}` };
37
+ }
38
+ }
39
+ // 지우기 — 파일은 바로, 폴더는 **비어 있을 때만**.
40
+ // 폰에서 한 번 잘못 누르면 되돌릴 수 없으므로, 안에 든 것까지 통째로 지우는 일은 하지 않는다
41
+ // (정말 필요하면 터미널에서 직접 지우면 된다).
42
+ export function deleteEntry(dir, name) {
43
+ const target = childPath(dir, name);
44
+ if (!target)
45
+ return { error: '지울 수 없는 이름이에요.' };
46
+ let stat;
47
+ try {
48
+ stat = fs.lstatSync(target);
49
+ }
50
+ catch {
51
+ return { error: '이미 없어요.' };
52
+ }
53
+ try {
54
+ if (stat.isDirectory()) {
55
+ const inside = fs.readdirSync(target);
56
+ if (inside.length > 0) {
57
+ return { error: `"${name}" 안에 ${inside.length}개가 들어 있어서 여기서는 못 지워요.` };
58
+ }
59
+ fs.rmdirSync(target);
60
+ return {};
61
+ }
62
+ fs.unlinkSync(target);
63
+ return {};
64
+ }
65
+ catch (err) {
66
+ return { error: `지우지 못했어요: ${err.message}` };
67
+ }
68
+ }
69
+ export function listDirs(dir, opts = {}) {
8
70
  const target = path.resolve(dir);
9
71
  const up = path.dirname(target);
10
72
  const base = {
@@ -23,8 +85,9 @@ export function listDirs(dir) {
23
85
  }
24
86
  const names = items
25
87
  .filter((entry) => {
26
- // 숨김 폴더(.git, .cache 등)는 제외 — 목록이 길어지고 세션을 열 곳도 아님
27
- if (entry.name.startsWith('.'))
88
+ // 숨김 폴더(.git, .cache 등)는 기본적으로 감춘다 — 목록이 길어지기 때문.
89
+ // 폰에서 「숨김 보기」를 켜면 그때만 함께 보여준다.
90
+ if (!opts.hidden && entry.name.startsWith('.'))
28
91
  return false;
29
92
  if (entry.isDirectory())
30
93
  return true;
@@ -43,7 +106,7 @@ export function listDirs(dir) {
43
106
  .sort((a, b) => a.localeCompare(b));
44
107
  // 파일은 이름과 크기만 — 열어볼 수 있는지 폰에서 미리 판단할 수 있게 한다
45
108
  const files = items
46
- .filter((entry) => !entry.name.startsWith('.') && entry.isFile())
109
+ .filter((entry) => (opts.hidden || !entry.name.startsWith('.')) && entry.isFile())
47
110
  .map((entry) => {
48
111
  try {
49
112
  return { name: entry.name, size: fs.statSync(path.join(target, entry.name)).size };
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 WEB_LOGIN_URL = 'https://shellbase.web.connectbase.world/login';
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/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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellbase",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {