shellbase 0.13.4 → 0.13.6
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 +233 -62
- package/dist/crypto.js +24 -4
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -11,7 +11,7 @@ const { Terminal: HeadlessTerminal } = requireCjs('@xterm/headless');
|
|
|
11
11
|
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
|
-
import { encryptFrame, decryptFrame, generateFrameKey } from './crypto.js';
|
|
14
|
+
import { encryptFrame, encryptBytes, decryptFrame, generateFrameKey, compress } 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';
|
|
@@ -39,16 +39,25 @@ const RESTORE_SCROLLBACK_LINES = 300;
|
|
|
39
39
|
// (빌드 로그·Claude Code 전체 다시 그리기 등) 프레임이 한도를 넘어 통째로 거부되고, 그 세션은 그 뒤로
|
|
40
40
|
// 아무 화면도 못 받는다. 폰에는 "검은 화면에 글자 하나 없는" 상태로 보인다 (제보).
|
|
41
41
|
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
42
|
+
// 조각은 적을수록 좋다. 조각이 둘 이상이면 "화면 지우기" 만 먼저 도착하고 내용이 뒤따라오는
|
|
43
|
+
// 사이가 그대로 검은 화면으로 보이고, 유실될 확률도 조각 수만큼 늘어난다.
|
|
44
|
+
//
|
|
45
|
+
// 기준은 **실제로 통로에 실리는 크기**다. 예전에는 원본을 정해진 바이트 수(40KB)로 자르고
|
|
46
|
+
// "암호화하면 55KB쯤 되겠지" 하고 짐작했는데, 압축이 들어오면 그 짐작이 아예 성립하지 않는다
|
|
47
|
+
// (얼마나 줄어들지 미리 알 수 없다). 짐작이 크면 한도를 넘어 거부되고(=검은 화면), 작으면
|
|
48
|
+
// 쓸데없이 잘게 쪼개진다(=느려짐). 그래서 아래 packOutput 이 압축·암호화까지 실제로 해보고
|
|
49
|
+
// 이 값과 재본다 — 짐작할 일이 없어지고, 압축이 잘 되는 화면은 통째로 한 조각에 담긴다.
|
|
50
|
+
// 64KB 한도에서 JSON 겉포장(kind/to/seq 등) 몫을 빼고 여유를 둔 값.
|
|
51
|
+
const WIRE_BUDGET_BYTES = 56 * 1024;
|
|
49
52
|
// 서버가 재는 기준이 예상과 다를 수도 있으니, "너무 크다" 고 거부당하면 스스로 절반씩 줄인다.
|
|
50
53
|
// 이 아래로는 줄이지 않는다 (더 줄여도 안 되면 크기 문제가 아니다).
|
|
51
|
-
const
|
|
54
|
+
const MIN_WIRE_BUDGET_BYTES = 8 * 1024;
|
|
55
|
+
// 이보다 작은 조각은 압축하지 않는다. gzip 은 머리말만 20바이트쯤 되므로 타자 한 글자가
|
|
56
|
+
// 되비치는 것(수십 바이트)까지 압축하면 오히려 커진다. 큰 화면에서만 이득을 챙기면 된다.
|
|
57
|
+
const GZIP_MIN_BYTES = 512;
|
|
58
|
+
// 조각이 한도를 넘을 때 반으로 잘라 다시 재보는 횟수 상한 (2^12 = 4096 조각이면 어떤 경우도 넘친다).
|
|
59
|
+
// 상한이 없으면 이상한 입력에서 영영 쪼개고 있을 수 있다.
|
|
60
|
+
const MAX_PACK_DEPTH = 12;
|
|
52
61
|
// 전송이 막혀 밀린 출력이 이만큼을 넘으면, 밀린 것을 하나하나 따라 보내는 대신 지금 화면을 통째로
|
|
53
62
|
// 다시 보낸다 — 사용자가 보게 될 최종 화면은 어차피 같고, 훨씬 빨리 따라잡는다.
|
|
54
63
|
const MAX_PENDING_OUTPUT = 256 * 1024;
|
|
@@ -66,11 +75,45 @@ const MAX_FRAMES_IN_FLIGHT = 6;
|
|
|
66
75
|
// 그래도 서버가 거부하면(RATE_LIMITED 등) 잠시 간격을 넉넉히 벌려서 재시도한다
|
|
67
76
|
const SEND_PENALTY_MS = 500;
|
|
68
77
|
const PENALTY_INTERVAL_MS = 250;
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
78
|
+
// ── 채널 페이싱 ─────────────────────────────────────────────────────────────
|
|
79
|
+
// 이 컴퓨터의 모든 세션이 실시간 통로(WebSocket) 하나를 함께 쓴다. 예전에는 여기를 12ms(초당 83건)
|
|
80
|
+
// 고정으로 두고 "실측해보니 서버가 40회 연속도 받아줬다"고 적어뒀는데, **그 실측은 세션이 하나일
|
|
81
|
+
// 때** 한 것이었다. 세션이 셋이 되는 순간 채널이 포화돼 ack 이 안 돌아왔고(`Request timeout`),
|
|
82
|
+
// 실패할 때마다 화면을 통째로 다시 보내는 바람에 프레임이 더 늘어나는 악순환이 생겼다
|
|
83
|
+
// ("타자가 사라졌다 3초 뒤에 몰아서 나타난다" 의 정체 — 세션 3개째를 연 지 50초 만에 시작됐다).
|
|
84
|
+
//
|
|
85
|
+
// 그래서 고정값을 버리고 **실제 왕복 시간을 재서 스스로 조절**한다. 잘 나가면 조금씩 좁히고,
|
|
86
|
+
// 실패하면 크게 벌린다(AIMD). 세션이 하나면 예전만큼 빠르고, 여러 개여도 스스로 안전한 값을 찾는다.
|
|
87
|
+
//
|
|
88
|
+
// 그런데 "간격 하나"로만 조절하는 것에도 한계가 있었다. 12ms 는 **초당 83건** 인데 서버 한도가
|
|
89
|
+
// 초당 100건이라, 세션 하나가 띄엄띄엄 보낼 때는 남아돌지만 세션 셋이 쉬지 않고 보내면 그 83건이
|
|
90
|
+
// 그대로 꽉 찬 상태로 계속된다 — 한도 코앞에 붙어서 달리는 셈이라 조금만 흔들려도 거부당했다.
|
|
91
|
+
// 반대로 타자를 칠 때는 12ms 조차 아깝다(글자 하나가 나가는 데 그만큼 밀린다).
|
|
92
|
+
//
|
|
93
|
+
// 그래서 "**몰아 보내기 여유 + 꾸준한 속도**" 두 가지로 나눠 잡는다(토큰 버킷).
|
|
94
|
+
// · 조용하다가 갑자기 보낼 때는 여유분만큼 **간격 없이** 곧바로 나간다 (타자 체감이 좋아진다)
|
|
95
|
+
// · 그 여유를 다 쓰면 그때부터 꾸준한 속도로 묶인다 (한도에 붙어 달리지 않는다)
|
|
96
|
+
// 즉 예전보다 순간적으로는 더 빠르고, 오래 이어질 때는 더 안전하다.
|
|
97
|
+
const CHANNEL_GAP_MIN_MS = 20;
|
|
98
|
+
const CHANNEL_GAP_MAX_MS = 200;
|
|
99
|
+
const CHANNEL_GAP_START_MS = 20;
|
|
100
|
+
// 규정 속도보다 이만큼 앞질러 나가는 것은 봐준다 = 몰아 보낼 수 있는 여유.
|
|
101
|
+
// 400ms 면 최소 간격 기준으로 20조각쯤을 간격 없이 연달아 부칠 수 있다. 조용했던 만큼만 쌓인다.
|
|
102
|
+
const CHANNEL_BURST_MS = 400;
|
|
103
|
+
// 실패했을 때 간격을 몇 배로 벌릴지
|
|
104
|
+
const CHANNEL_GAP_GROW = 2;
|
|
105
|
+
// 좁히는 것은 **시간**으로 한다. 예전에는 성공 한 번에 1ms 씩 좁혔는데, 200ms 까지 벌어지면
|
|
106
|
+
// 되돌아오는 데 성공 188번이 필요했다 — 그 뒤로 뜸해진 세션은 한참을 느린 채로 남았다.
|
|
107
|
+
// 이제는 마지막 실패로부터 이만큼 지날 때마다 한 단계씩 좁힌다 (조용해도 저절로 회복한다).
|
|
108
|
+
const CHANNEL_RECOVER_MS = 5_000;
|
|
109
|
+
const CHANNEL_GAP_SHRINK = 0.7;
|
|
110
|
+
// 슬롯 예약이 이만큼 넘게 밀렸으면 = 통로가 막혔다는 뜻. 이때는 프레임을 **더 부치지 않고**
|
|
111
|
+
// 출력을 계속 모은다(coalescing). 터미널은 "최종 화면"만 맞으면 되므로 중간 프레임을 합쳐도
|
|
112
|
+
// 잃는 게 없다 — 오히려 막힌 통로에 프레임을 밀어 넣지 않아서 훨씬 빨리 따라잡는다.
|
|
113
|
+
const MAX_SLOT_LAG_MS = 250;
|
|
114
|
+
// 폰이 이 세션을 보고 있다고 볼 수 있는 시간. 폰은 보고 있는 동안 10초마다 ping 을 보내므로
|
|
115
|
+
// 세 번 놓칠 때까지 기다린다 (화면 잠금·터널 전환 등으로 잠깐 끊기는 것과 구분).
|
|
116
|
+
const VIEWER_ACTIVE_MS = 30_000;
|
|
74
117
|
// 화면 복원 전에 "지금까지 나온 출력이 보이지 않는 터미널에 다 반영됐는지" 기다리는 횟수.
|
|
75
118
|
// 기다리는 동안 새 출력이 또 들어오면 한 번 더 기다린다 — 쉬지 않고 쏟아지는 세션에서 영영
|
|
76
119
|
// 못 기다리는 일이 없도록 상한을 둔다.
|
|
@@ -97,23 +140,14 @@ const IDLE_MS = 4_000; // 이만큼 조용하면 "끝났다" 로 본다
|
|
|
97
140
|
// 시간만 보면 "천천히 오래 타이핑한 것" 도 일하는 중으로 잘못 본다(글자마다 화면에 되비치므로).
|
|
98
141
|
// 실제 작업은 출력량이 비교가 안 되게 많아서(회전 표시·중간 결과·빌드 로그) 양으로 걸러낸다.
|
|
99
142
|
const BUSY_MIN_BYTES = 2048;
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
while (start < buf.length) {
|
|
109
|
-
let end = Math.min(buf.length, start + budget);
|
|
110
|
-
// UTF-8 에서 이어지는 바이트는 10xxxxxx 로 시작한다 — 거기서 끊으면 글자가 반쪽이 된다
|
|
111
|
-
while (end > start + 1 && end < buf.length && (buf[end] & 0xc0) === 0x80)
|
|
112
|
-
end--;
|
|
113
|
-
parts.push(buf.toString('utf8', start, end));
|
|
114
|
-
start = end;
|
|
115
|
-
}
|
|
116
|
-
return parts;
|
|
143
|
+
// 반으로 자를 자리. 이모지처럼 두 칸을 차지하는 글자의 뒷짝 바로 앞에서 끊으면 글자가 깨지므로
|
|
144
|
+
// 그때는 한 칸 옮긴다. (자바스크립트 글자열은 UTF-16 이라 0xDC00~0xDFFF 가 '뒷짝' 이다)
|
|
145
|
+
function splitPoint(text) {
|
|
146
|
+
let mid = text.length >> 1;
|
|
147
|
+
const code = text.charCodeAt(mid);
|
|
148
|
+
if (code >= 0xdc00 && code <= 0xdfff)
|
|
149
|
+
mid += 1;
|
|
150
|
+
return Math.min(Math.max(mid, 1), text.length - 1);
|
|
117
151
|
}
|
|
118
152
|
// 폰의 폴더 탐색기를 열 때 "지금 셸이 있는 폴더"에서 시작하려면, 세션을 시작한 폴더가 아니라
|
|
119
153
|
// 셸 프로세스의 실제 작업 폴더를 봐야 한다(사용자가 cd 로 옮겨 다니므로). 리눅스는 /proc 로 바로 알 수 있고,
|
|
@@ -223,13 +257,47 @@ export async function runAgent(options) {
|
|
|
223
257
|
.map((session) => ({ dir: session.cwd, name: session.name })));
|
|
224
258
|
}
|
|
225
259
|
// 이 컴퓨터의 모든 세션이 실시간 통로(WebSocket) 하나를 함께 쓴다 — 채널 전체의 전송 간격은
|
|
226
|
-
// 여기서 지킨다. 세션마다 따로 간격을 지켜도 세션이 여럿이면 채널로는
|
|
227
|
-
//
|
|
260
|
+
// 여기서 지킨다. 세션마다 따로 간격을 지켜도 세션이 여럿이면 채널로는 한도를 넘고, 넘는 순간부터
|
|
261
|
+
// ack 이 안 돌아와 그 출력이 밀리고 화면이 멈춘다.
|
|
262
|
+
//
|
|
263
|
+
// 간격은 **고정하지 않는다**. 실패하면 두 배로 벌리고, 실패 없이 시간이 지나면 스스로 좁힌다.
|
|
264
|
+
// 고정값은 "세션 하나에서 잰 값"이라 세션이 늘어나는 순간 그대로 무너졌다 (§상수 주석 참고).
|
|
265
|
+
let channelGapMs = CHANNEL_GAP_START_MS;
|
|
266
|
+
let lastWidenAt = 0;
|
|
267
|
+
// 좁히기는 시간이 지나면 저절로 된다 — 프레임이 오가는 길목에서 확인한다.
|
|
268
|
+
// (예전에는 "성공 한 번에 1ms" 라, 뜸해진 세션은 영영 느린 채로 남았다)
|
|
269
|
+
const easeChannelGap = () => {
|
|
270
|
+
if (channelGapMs <= CHANNEL_GAP_MIN_MS)
|
|
271
|
+
return;
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
if (now - lastWidenAt < CHANNEL_RECOVER_MS)
|
|
274
|
+
return;
|
|
275
|
+
lastWidenAt = now;
|
|
276
|
+
channelGapMs = Math.max(CHANNEL_GAP_MIN_MS, Math.round(channelGapMs * CHANNEL_GAP_SHRINK));
|
|
277
|
+
console.error(`통로가 한동안 조용해서 전송 간격을 ${channelGapMs}ms 로 되돌렸어요`);
|
|
278
|
+
};
|
|
279
|
+
const widenChannelGap = (why) => {
|
|
280
|
+
lastWidenAt = Date.now();
|
|
281
|
+
if (channelGapMs >= CHANNEL_GAP_MAX_MS)
|
|
282
|
+
return;
|
|
283
|
+
channelGapMs = Math.min(CHANNEL_GAP_MAX_MS, Math.max(1, channelGapMs) * CHANNEL_GAP_GROW);
|
|
284
|
+
console.error(`통로가 막혀서 전송 간격을 ${channelGapMs}ms 로 벌렸어요 (${why})`);
|
|
285
|
+
};
|
|
286
|
+
// "이 시각 이후여야 규정 속도에 맞는다" 는 가상의 시계. 부칠 때마다 간격만큼 앞으로 간다.
|
|
287
|
+
// 조용히 있으면 이 시계가 현재보다 뒤처지는데, **그 뒤처진 만큼이 곧 몰아 보낼 여유**다.
|
|
228
288
|
let nextSlotAt = 0;
|
|
289
|
+
// 지금 예약이 얼마나 미래로 밀려 있는지 (몰아 보내기 여유를 빼고 센다). 이게 크면 = 통로가
|
|
290
|
+
// 막혔다는 뜻이고, 그때 프레임을 더 부치면 상황이 나빠지기만 한다 (3초씩 밀리던 직접 원인).
|
|
291
|
+
const slotLagMs = () => Math.max(0, nextSlotAt - CHANNEL_BURST_MS - Date.now());
|
|
229
292
|
const takeSendSlot = async () => {
|
|
230
293
|
const now = Date.now();
|
|
231
|
-
|
|
232
|
-
|
|
294
|
+
// 규정상 나갈 시각. 조용했으면 지금이고, 몰아 보내는 중이면 앞선 것들 뒤로 밀린다.
|
|
295
|
+
const due = Math.max(now, nextSlotAt);
|
|
296
|
+
nextSlotAt = due + channelGapMs;
|
|
297
|
+
// 규정보다 CHANNEL_BURST_MS 만큼 앞질러 나가는 것은 봐준다 — 조각 몇 개는 간격 없이 곧바로
|
|
298
|
+
// 나가고, 여유를 다 쓴 뒤에야 꾸준한 속도로 묶인다.
|
|
299
|
+
// (예약을 **부르는 즉시** 잡아두므로 부른 순서 = 나가는 순서는 그대로 지켜진다)
|
|
300
|
+
const at = Math.max(now, due - CHANNEL_BURST_MS);
|
|
233
301
|
if (at > now)
|
|
234
302
|
await new Promise((resolve) => setTimeout(resolve, at - now));
|
|
235
303
|
};
|
|
@@ -237,7 +305,16 @@ export async function runAgent(options) {
|
|
|
237
305
|
// (그래서 아래에서 여러 조각을 한꺼번에 부쳐도 순서가 지켜진다).
|
|
238
306
|
const sendFrame = async (frame) => {
|
|
239
307
|
await takeSendSlot();
|
|
240
|
-
|
|
308
|
+
try {
|
|
309
|
+
await channel.send(frame, { includeSelf: false });
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
// 실패 = 통로가 감당 못 하고 있다는 가장 확실한 신호. 여기서 간격을 벌려두면
|
|
313
|
+
// 뒤따르는 프레임들이 자동으로 느슨해져서 스스로 회복한다.
|
|
314
|
+
widenChannelGap(err.message);
|
|
315
|
+
throw err;
|
|
316
|
+
}
|
|
317
|
+
easeChannelGap();
|
|
241
318
|
};
|
|
242
319
|
const send = async (frame) => {
|
|
243
320
|
try {
|
|
@@ -254,17 +331,42 @@ export async function runAgent(options) {
|
|
|
254
331
|
session.sendChain = next.then(() => undefined, () => undefined);
|
|
255
332
|
return next;
|
|
256
333
|
};
|
|
257
|
-
// 실제로 쓰는 조각
|
|
258
|
-
// 크기 때문에 거부당하는 것은 다시 보내도 똑같이 거부되므로
|
|
259
|
-
// 재시도 전에 반드시 크기를 줄여야 한다.
|
|
260
|
-
let
|
|
261
|
-
const
|
|
262
|
-
if (
|
|
334
|
+
// 실제로 쓰는 조각 크기 (통로에 실리는 크기 기준). 서버가 "너무 크다" 고 거부하면 절반으로
|
|
335
|
+
// 줄여서 스스로 맞춘다 — 크기 때문에 거부당하는 것은 다시 보내도 똑같이 거부되므로
|
|
336
|
+
// (= 검은 화면이 영영 안 풀린다), 재시도 전에 반드시 크기를 줄여야 한다.
|
|
337
|
+
let wireBudget = WIRE_BUDGET_BYTES;
|
|
338
|
+
const shrinkWireBudget = (why) => {
|
|
339
|
+
if (wireBudget <= MIN_WIRE_BUDGET_BYTES)
|
|
263
340
|
return false;
|
|
264
|
-
|
|
265
|
-
console.error(`조각 크기를 ${Math.round(
|
|
341
|
+
wireBudget = Math.max(MIN_WIRE_BUDGET_BYTES, Math.floor(wireBudget / 2));
|
|
342
|
+
console.error(`조각 크기를 ${Math.round(wireBudget / 1024)}KB 로 줄였어요 (${why})`);
|
|
266
343
|
return true;
|
|
267
344
|
};
|
|
345
|
+
// 보낼 글을 프레임 조각들로 만든다 — **압축·암호화까지 실제로 해보고 크기를 재서** 나눈다.
|
|
346
|
+
//
|
|
347
|
+
// 압축을 넣으면 "원본 몇 바이트로 자를까" 는 답이 없는 질문이 된다. 같은 40KB라도 색이 촘촘한
|
|
348
|
+
// 화면과 회전 표시만 도는 화면은 압축 후 크기가 열 배 넘게 차이 난다. 짐작해서 자르면 크게
|
|
349
|
+
// 잡을 땐 한도를 넘어 통째로 거부되고(폰은 새까만 화면), 작게 잡을 땐 한 조각이면 될 것을
|
|
350
|
+
// 다섯 조각으로 쪼갠다(조각 사이 틈 = 검은 화면, 유실 확률도 조각 수만큼).
|
|
351
|
+
//
|
|
352
|
+
// 그래서 짐작하지 않는다. 통째로 담아보고, 안 들어가면 반으로 잘라 다시 담아본다.
|
|
353
|
+
// 잘 압축되는 화면은 그대로 **한 조각**으로 끝난다 (실측: 25KB 출력 → 7KB, 약 4.8배).
|
|
354
|
+
const packOutput = (session, text) => {
|
|
355
|
+
const pack = (part, depth) => {
|
|
356
|
+
// 타자 한 글자가 되비치는 것까지 압축하면 머리말 때문에 오히려 커진다 — 큰 것만 압축한다
|
|
357
|
+
const useGz = session.gzipOk && Buffer.byteLength(part, 'utf8') >= GZIP_MIN_BYTES;
|
|
358
|
+
const data = useGz
|
|
359
|
+
? encryptBytes(compress(part), session.frameKey)
|
|
360
|
+
: encryptFrame(part, session.frameKey);
|
|
361
|
+
// 한 글자까지 줄여도 안 들어가면 더 쪼갤 수가 없다 — 그냥 보내고 결과에 맡긴다
|
|
362
|
+
if (data.length <= wireBudget || part.length <= 1 || depth >= MAX_PACK_DEPTH) {
|
|
363
|
+
return [useGz ? { data, gz: true } : { data }];
|
|
364
|
+
}
|
|
365
|
+
const cut = splitPoint(part);
|
|
366
|
+
return [...pack(part.slice(0, cut), depth + 1), ...pack(part.slice(cut), depth + 1)];
|
|
367
|
+
};
|
|
368
|
+
return text ? pack(text, 0) : [];
|
|
369
|
+
};
|
|
268
370
|
// 지금까지 나온 출력이 '보이지 않는 터미널' 에 다 반영되기를 기다린다.
|
|
269
371
|
// (xterm 은 받은 글자를 곧바로가 아니라 조금 뒤에 처리한다 — 실측 확인)
|
|
270
372
|
const drainScreen = (session) => new Promise((resolve) => {
|
|
@@ -297,7 +399,8 @@ export async function runAgent(options) {
|
|
|
297
399
|
const results = await Promise.allSettled(parts.map((part, index) => sendFrame({
|
|
298
400
|
kind: 'output',
|
|
299
401
|
to: session.deviceId,
|
|
300
|
-
data:
|
|
402
|
+
data: part.data,
|
|
403
|
+
...(part.gz ? { gz: true } : {}),
|
|
301
404
|
// 복원 조각임을 표시해서, 폰이 다 모은 뒤 한 번에 그리게 한다 (검은 화면 방지)
|
|
302
405
|
seq: index,
|
|
303
406
|
total: parts.length,
|
|
@@ -311,7 +414,7 @@ export async function runAgent(options) {
|
|
|
311
414
|
const message = String(reason?.message ?? reason);
|
|
312
415
|
console.error(`출력 전송 실패 (${sent}/${parts.length} 조각만 나감):`, message);
|
|
313
416
|
if (/too large|TOO_LARGE|size|크기/i.test(message))
|
|
314
|
-
|
|
417
|
+
shrinkWireBudget(message);
|
|
315
418
|
session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
|
|
316
419
|
}
|
|
317
420
|
return sent;
|
|
@@ -340,15 +443,17 @@ export async function runAgent(options) {
|
|
|
340
443
|
// 없다가 한꺼번에 쏟아지는 것처럼 보였다 — "타자가 2초 뒤에 몰아서 보인다" 의 정체.
|
|
341
444
|
await enqueueSend(session, async () => {
|
|
342
445
|
try {
|
|
343
|
-
// 프레임 한도를 넘지 않도록 나눠 보낸다 (한 덩이로 보내면 통째로 거부돼 화면이 멈춘다)
|
|
344
|
-
|
|
446
|
+
// 프레임 한도를 넘지 않도록 나눠 보낸다 (한 덩이로 보내면 통째로 거부돼 화면이 멈춘다).
|
|
447
|
+
// 압축이 되는 폰이면 대개 한 조각으로 끝난다 — 조각이 적을수록 빠르고 덜 유실된다.
|
|
448
|
+
const parts = packOutput(session, pending);
|
|
345
449
|
session.inFlight += parts.length;
|
|
346
450
|
// send() 를 부르는 순간 이미 통로로 나간다. 부른 순서 = 나가는 순서라서(실측 확인)
|
|
347
451
|
// 답을 기다리지 않아도 조각이 뒤섞이지 않는다.
|
|
348
452
|
const results = parts.map((part) => sendFrame({
|
|
349
453
|
kind: 'output',
|
|
350
454
|
to: session.deviceId,
|
|
351
|
-
data:
|
|
455
|
+
data: part.data,
|
|
456
|
+
...(part.gz ? { gz: true } : {}),
|
|
352
457
|
}));
|
|
353
458
|
void Promise.allSettled(results)
|
|
354
459
|
.then((settled) => {
|
|
@@ -361,15 +466,15 @@ export async function runAgent(options) {
|
|
|
361
466
|
const message = String(reason?.message ?? reason);
|
|
362
467
|
console.error(`출력 전송 실패 (${sent}/${parts.length} 조각만 나감):`, message);
|
|
363
468
|
if (/too large|TOO_LARGE|size|크기/i.test(message))
|
|
364
|
-
|
|
469
|
+
shrinkWireBudget(message);
|
|
365
470
|
session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
|
|
366
|
-
// 못 간 것을 다시 보내는 대신 **화면을 통째로 다시
|
|
471
|
+
// 못 간 것을 다시 보내는 대신 **화면을 통째로 다시 맞춘다**.
|
|
367
472
|
// 답을 기다리지 않고 잇달아 부치기 때문에, 실패를 알게 된 시점에는 그 뒤의 출력이
|
|
368
473
|
// 이미 나가 있을 수 있다 — 거기에 옛 조각을 뒤늦게 끼워 넣으면 화면이 뒤섞인다.
|
|
369
474
|
// 지금 화면이 어떻게 생겼는지는 session.screen 이 정확히 들고 있어서,
|
|
370
475
|
// 통째로 다시 보내면 빠지지도 겹치지도 않는다.
|
|
371
|
-
|
|
372
|
-
|
|
476
|
+
// 다만 **그 자리에서 바로 보내지는 않는다** — scheduleResync 주석 참고.
|
|
477
|
+
scheduleResync(session, message);
|
|
373
478
|
})
|
|
374
479
|
.finally(() => {
|
|
375
480
|
session.inFlight -= parts.length;
|
|
@@ -394,6 +499,16 @@ export async function runAgent(options) {
|
|
|
394
499
|
};
|
|
395
500
|
// 폰이 "화면을 통째로 다시 달라"고 할 때 (처음 접속·재연결·크기 변경 뒤).
|
|
396
501
|
const sendRestore = async (session) => {
|
|
502
|
+
// 아무도 안 보는 세션에 화면을 보내봐야 받는 사람이 없다 — 통로만 먹는다.
|
|
503
|
+
// (다시 보기 시작하면 그때 ping/connect_request 가 오고, 거기서 복원한다)
|
|
504
|
+
if (!hasViewer(session)) {
|
|
505
|
+
if (session.resyncTimer !== null) {
|
|
506
|
+
clearTimeout(session.resyncTimer);
|
|
507
|
+
session.resyncTimer = null;
|
|
508
|
+
}
|
|
509
|
+
session.outBuffer = '';
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
397
512
|
if (session.restoring) {
|
|
398
513
|
// 보내는 중에 또 요청이 왔다 — 지금 것이 끝난 뒤 한 번만 더 보낸다.
|
|
399
514
|
// 두 벌을 동시에 보내면 조각이 서로 끼어들어 폰 화면에 같은 내용이 두 번 그려진다.
|
|
@@ -444,7 +559,7 @@ export async function runAgent(options) {
|
|
|
444
559
|
}
|
|
445
560
|
// 화면을 먼저 지우고(2J·3J) 복원 내용을 보내야, 폰에 남아있던 예전 화면과 섞이지 않는다
|
|
446
561
|
const payload = `\x1b[H\x1b[2J\x1b[3J${snapshot}`;
|
|
447
|
-
const parts =
|
|
562
|
+
const parts = packOutput(session, payload);
|
|
448
563
|
const sent = await enqueueSend(session, () => sendParts(session, parts));
|
|
449
564
|
if (sent < parts.length) {
|
|
450
565
|
// 화면을 지우라는 첫 조각만 도착하고 내용이 끊긴 상태 = 폰에는 **새까만 화면**만 남는다.
|
|
@@ -482,7 +597,7 @@ export async function runAgent(options) {
|
|
|
482
597
|
// 두 번 연달아 실패하면 이유가 뭐라고 적혀 있든 크기부터 의심한다 — 같은 크기로 계속
|
|
483
598
|
// 다시 보내봐야 같은 이유로 막히고, 그동안 폰은 계속 새까만 화면이다.
|
|
484
599
|
if (session.restoreFails >= 2)
|
|
485
|
-
|
|
600
|
+
shrinkWireBudget('화면 복원이 거듭 실패');
|
|
486
601
|
console.error(`화면 복원이 중간에 끊겼어요 ("${session.name}") — ${wait}ms 뒤 다시 보낼게요.`);
|
|
487
602
|
session.restoreRetry = setTimeout(() => {
|
|
488
603
|
session.restoreRetry = null;
|
|
@@ -490,6 +605,26 @@ export async function runAgent(options) {
|
|
|
490
605
|
void sendRestore(session);
|
|
491
606
|
}, wait);
|
|
492
607
|
};
|
|
608
|
+
// 폰이 지금 이 세션을 보고 있는가. 폰은 보고 있는 세션에만 10초마다 ping 을 보내므로,
|
|
609
|
+
// 마지막 신호가 최근이면 "보고 있다" 로 본다. 이 판단 하나가 채널 부하를 세션 수만큼 줄인다.
|
|
610
|
+
const hasViewer = (session) => Date.now() - session.viewerSeenAt < VIEWER_ACTIVE_MS;
|
|
611
|
+
// 화면을 통째로 다시 맞춰야 할 때 쓴다 — 단, **곧바로 보내지 않는다**.
|
|
612
|
+
// 예전에는 프레임 하나가 실패할 때마다 그 자리에서 화면 전체를 다시 보냈다. 통로가 막혀서
|
|
613
|
+
// 실패한 것인데 가장 큰 프레임을 더 밀어 넣는 셈이라, 실패가 실패를 부르며 눈덩이처럼 커졌다
|
|
614
|
+
// (실패 106회 · 복원까지 연쇄 실패). 이제는 잠깐 모았다가 **한 번만** 보낸다 — 그 사이 실패가
|
|
615
|
+
// 열 번 더 나도 복원은 여전히 한 번이고, 통로에는 숨 쉴 틈이 생긴다.
|
|
616
|
+
const scheduleResync = (session, why) => {
|
|
617
|
+
if (session.closing || session.resyncTimer !== null)
|
|
618
|
+
return;
|
|
619
|
+
// 통로가 밀려 있으면 그만큼 더 기다렸다 보낸다 (막힌 곳에 큰 프레임을 얹지 않는다)
|
|
620
|
+
const wait = Math.max(SEND_PENALTY_MS, slotLagMs());
|
|
621
|
+
console.error(`화면을 ${wait}ms 뒤에 한 번 다시 맞출게요 ("${session.name}" — ${why})`);
|
|
622
|
+
session.resyncTimer = setTimeout(() => {
|
|
623
|
+
session.resyncTimer = null;
|
|
624
|
+
if (!session.closing)
|
|
625
|
+
void sendRestore(session);
|
|
626
|
+
}, wait);
|
|
627
|
+
};
|
|
493
628
|
const scheduleFlush = (session) => {
|
|
494
629
|
if (session.pendingFlush !== null || session.flushing || session.restoring)
|
|
495
630
|
return;
|
|
@@ -498,7 +633,11 @@ export async function runAgent(options) {
|
|
|
498
633
|
if (session.inFlight >= MAX_FRAMES_IN_FLIGHT)
|
|
499
634
|
return;
|
|
500
635
|
const interval = Date.now() < session.sendPenaltyUntil ? PENALTY_INTERVAL_MS : MIN_SEND_INTERVAL_MS;
|
|
501
|
-
|
|
636
|
+
// 통로가 막혀 슬롯 예약이 한참 밀려 있으면, 지금 부쳐봐야 그 프레임은 몇 초 뒤에나 나간다.
|
|
637
|
+
// 그럴 바엔 **부치지 말고 계속 모은다** — 밀린 만큼 기다렸다 한 덩이로 보내면 조각 수가
|
|
638
|
+
// 늘지 않아 통로가 스스로 풀리고, 폰에는 오히려 더 빨리 최신 화면이 도착한다.
|
|
639
|
+
const lag = slotLagMs();
|
|
640
|
+
const wait = lag > MAX_SLOT_LAG_MS ? lag : Math.max(0, interval - (Date.now() - session.lastSentAt));
|
|
502
641
|
session.pendingFlush = setTimeout(() => void flush(session), wait);
|
|
503
642
|
};
|
|
504
643
|
// ── 작업 완료 알림 ──────────────────────────────────────────────────────────
|
|
@@ -623,6 +762,7 @@ export async function runAgent(options) {
|
|
|
623
762
|
restoreAgain: false,
|
|
624
763
|
restoreRetry: null,
|
|
625
764
|
restoreFails: 0,
|
|
765
|
+
resyncTimer: null,
|
|
626
766
|
lastSentAt: 0,
|
|
627
767
|
sendPenaltyUntil: 0,
|
|
628
768
|
pendingFlush: null,
|
|
@@ -635,14 +775,26 @@ export async function runAgent(options) {
|
|
|
635
775
|
hookSeen: false,
|
|
636
776
|
temporary,
|
|
637
777
|
viewerSeenAt: Date.now(),
|
|
778
|
+
// 폰이 접속하면서 "압축 읽을 수 있다" 고 알려주기 전까지는 압축하지 않는다
|
|
779
|
+
gzipOk: false,
|
|
638
780
|
};
|
|
639
781
|
session.screen.loadAddon(session.serializer);
|
|
640
782
|
session.pty.onData((data) => {
|
|
641
|
-
|
|
642
|
-
//
|
|
783
|
+
// 화면 상태는 보든 안 보든 항상 따라 그려둔다 — 여기가 "지금 화면이 어떻게 생겼는지" 의
|
|
784
|
+
// 유일한 진실이고, 폰이 (다시) 들어오면 여기서 통째로 복원해 보낸다.
|
|
643
785
|
session.screen.write(data);
|
|
644
|
-
scheduleFlush(session);
|
|
645
786
|
trackBusy(session, data.length);
|
|
787
|
+
// 🔴 **아무도 안 보고 있으면 실시간 프레임을 아예 만들지 않는다.**
|
|
788
|
+
// 폰은 한 번에 세션 하나만 화면에 띄우는데, 예전에는 열려 있는 모든 세션이 각자 출력을
|
|
789
|
+
// 채널로 밀어 넣고 있었다. 세션이 넷이면 채널 부하가 그대로 4배가 되고, 정작 폰은 그중
|
|
790
|
+
// 셋을 받아서 버렸다 — 보이지도 않는 화면 때문에 보이는 화면이 3초씩 밀렸던 것이다.
|
|
791
|
+
// 안 보는 동안 쌓인 것은 위 screen 에 이미 다 들어 있으므로 하나도 잃지 않는다.
|
|
792
|
+
if (!hasViewer(session)) {
|
|
793
|
+
session.outBuffer = '';
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
session.outBuffer += data;
|
|
797
|
+
scheduleFlush(session);
|
|
646
798
|
});
|
|
647
799
|
session.pty.onExit(() => {
|
|
648
800
|
console.log(`"${session.name}" 세션의 셸이 종료돼서 세션도 닫아요.`);
|
|
@@ -679,6 +831,8 @@ export async function runAgent(options) {
|
|
|
679
831
|
clearTimeout(session.pendingFlush);
|
|
680
832
|
if (session.restoreRetry !== null)
|
|
681
833
|
clearTimeout(session.restoreRetry);
|
|
834
|
+
if (session.resyncTimer !== null)
|
|
835
|
+
clearTimeout(session.resyncTimer);
|
|
682
836
|
if (session.idleTimer !== null)
|
|
683
837
|
clearTimeout(session.idleTimer);
|
|
684
838
|
await flush(session);
|
|
@@ -757,8 +911,12 @@ export async function runAgent(options) {
|
|
|
757
911
|
// 접두사를 붙여 UUID 로 보이지 않게 만든다. 프레임 배분은 userId 가 아니라 frame.to 로 한다.
|
|
758
912
|
// timeout 은 "보낸 것에 서버가 답하기를 기다리는 시간" 이기도 하다. 기본값 30초는 터미널에는
|
|
759
913
|
// 너무 길다 — 한 번 막히면 그 세션의 화면이 30초 동안 통째로 멈춘 것처럼 보이고, 그 사이 밀린
|
|
760
|
-
// 출력이 더 쌓여서 회복도 늦어진다.
|
|
761
|
-
|
|
914
|
+
// 출력이 더 쌓여서 회복도 늦어진다.
|
|
915
|
+
//
|
|
916
|
+
// 10초도 여전히 길었다. 답을 못 받은 조각은 MAX_FRAMES_IN_FLIGHT(6) 자리를 물고 있는데, 그게
|
|
917
|
+
// 전부 타임아웃을 기다리면 그 세션은 **10초 동안 아무 화면도 못 보낸다**. 이 컴퓨터↔서버 왕복은
|
|
918
|
+
// 정상일 때 0.3초 안쪽이라, 4초면 느린 회선까지 충분히 품으면서 막혔을 때 회복이 두 배 빠르다.
|
|
919
|
+
await cb.realtime.connect({ userId: `agent-${crypto.randomUUID()}`, timeout: 4_000 });
|
|
762
920
|
const channel = await cb.realtime.subscribe(TERMINAL_CATEGORY);
|
|
763
921
|
// 이 채널은 앱의 모든 멤버가 공유하므로, 프레임 내용(data)은 세션별 키로 암호화해서 주고받는다.
|
|
764
922
|
// Claude Code 훅이 남긴 "대답 마쳤어요" 쪽지를 받는다 (notify.ts).
|
|
@@ -782,8 +940,21 @@ export async function runAgent(options) {
|
|
|
782
940
|
const session = sessions.get(frame.to);
|
|
783
941
|
if (!session || session.closing)
|
|
784
942
|
return;
|
|
785
|
-
// 폰에서 온 프레임 = 누군가 이 세션을 보고 있다는 뜻 (임시 터미널
|
|
943
|
+
// 폰에서 온 프레임 = 누군가 이 세션을 보고 있다는 뜻 (임시 터미널 정리·출력 게이트에 쓴다)
|
|
944
|
+
const wasUnwatched = !hasViewer(session);
|
|
786
945
|
session.viewerSeenAt = Date.now();
|
|
946
|
+
// 압축된 화면을 읽을 수 있는 폰인지. 접속할 때(connect_request)는 **그 폰이 말한 그대로** 맞춘다 —
|
|
947
|
+
// 옛 폰이 새로 붙으면 압축을 꺼야 하기 때문이다(못 읽는 폰에 보내면 화면이 통째로 깨진다).
|
|
948
|
+
// 그 밖의 프레임에서는 켜기만 한다(핑·크기변경에는 이 표시가 없을 수 있으므로 끄면 안 된다).
|
|
949
|
+
if (frame.kind === 'connect_request')
|
|
950
|
+
session.gzipOk = frame.gzOk === true;
|
|
951
|
+
else if (frame.gzOk)
|
|
952
|
+
session.gzipOk = true;
|
|
953
|
+
// 안 보는 동안에는 실시간 프레임을 만들지 않으므로(pty.onData 참고), 다시 보기 시작하면
|
|
954
|
+
// 그 사이 화면이 어떻게 바뀌었는지 알 길이 없다 — 지금 화면을 통째로 보내 맞춰준다.
|
|
955
|
+
// connect_request 는 아래에서 어차피 복원하므로 여기서 두 번 하지 않는다.
|
|
956
|
+
if (wasUnwatched && frame.kind !== 'connect_request')
|
|
957
|
+
void sendRestore(session);
|
|
787
958
|
if (frame.kind === 'connect_request') {
|
|
788
959
|
// 화면 보기는 승인 여부와 무관하게 항상 되므로, 승인을 묻기 전에 지금 화면부터 그대로 복원해준다
|
|
789
960
|
void sendRestore(session);
|
package/dist/crypto.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
+
import zlib from 'node:zlib';
|
|
2
3
|
const ALGO = 'aes-256-gcm';
|
|
3
4
|
const IV_LEN = 12;
|
|
4
5
|
const TAG_LEN = 16;
|
|
@@ -9,15 +10,15 @@ const TAG_LEN = 16;
|
|
|
9
10
|
export function generateFrameKey() {
|
|
10
11
|
return crypto.randomBytes(32).toString('base64');
|
|
11
12
|
}
|
|
12
|
-
export function
|
|
13
|
+
export function encryptBytes(plain, keyB64) {
|
|
13
14
|
const key = Buffer.from(keyB64, 'base64');
|
|
14
15
|
const iv = crypto.randomBytes(IV_LEN);
|
|
15
16
|
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
|
16
|
-
const ciphertext = Buffer.concat([cipher.update(
|
|
17
|
+
const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]);
|
|
17
18
|
const authTag = cipher.getAuthTag();
|
|
18
19
|
return Buffer.concat([iv, ciphertext, authTag]).toString('base64');
|
|
19
20
|
}
|
|
20
|
-
export function
|
|
21
|
+
export function decryptToBytes(payloadB64, keyB64) {
|
|
21
22
|
const key = Buffer.from(keyB64, 'base64');
|
|
22
23
|
const raw = Buffer.from(payloadB64, 'base64');
|
|
23
24
|
const iv = raw.subarray(0, IV_LEN);
|
|
@@ -25,5 +26,24 @@ export function decryptFrame(payloadB64, keyB64) {
|
|
|
25
26
|
const ciphertext = raw.subarray(IV_LEN, raw.length - TAG_LEN);
|
|
26
27
|
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
|
27
28
|
decipher.setAuthTag(authTag);
|
|
28
|
-
return Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
|
29
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
30
|
+
}
|
|
31
|
+
export function encryptFrame(plaintext, keyB64) {
|
|
32
|
+
return encryptBytes(Buffer.from(plaintext, 'utf8'), keyB64);
|
|
33
|
+
}
|
|
34
|
+
export function decryptFrame(payloadB64, keyB64) {
|
|
35
|
+
return decryptToBytes(payloadB64, keyB64).toString('utf8');
|
|
36
|
+
}
|
|
37
|
+
// ── 압축 ────────────────────────────────────────────────────────────────────
|
|
38
|
+
// 터미널 출력은 같은 것이 끝없이 반복된다(색 코드·회전 표시·화면 전체 다시 그리기). 실측으로
|
|
39
|
+
// 5배 안팎 줄어드는데, 이 통로의 병목은 굵기가 아니라 **초당 보낼 수 있는 건수**라서 크기가
|
|
40
|
+
// 줄면 조각 수가 그대로 줄어든다 — 조각 사이의 틈이 곧 검은 화면이었으므로 효과가 크다.
|
|
41
|
+
//
|
|
42
|
+
// 반드시 **압축 → 암호화** 순서다. 암호문은 무작위에 가까워서 뒤에 압축하면 오히려 커진다.
|
|
43
|
+
// 압축 비용은 실측 0.68ms/256KB (level 6) 로 사실상 없다.
|
|
44
|
+
export function compress(plaintext) {
|
|
45
|
+
return zlib.gzipSync(Buffer.from(plaintext, 'utf8'));
|
|
46
|
+
}
|
|
47
|
+
export function decompress(raw) {
|
|
48
|
+
return zlib.gunzipSync(raw).toString('utf8');
|
|
29
49
|
}
|