deel-local-cli 1.5.7 → 1.6.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.
Files changed (57) hide show
  1. package/README.en.md +24 -13
  2. package/README.md +23 -12
  3. package/bin/deel.js +10 -0
  4. package/package.json +2 -2
  5. package/src/acp/map.js +143 -0
  6. package/src/acp/serve.js +160 -13
  7. package/src/agent/commit.js +511 -0
  8. package/src/agent/compact.js +269 -226
  9. package/src/agent/loop.js +154 -12
  10. package/src/agent/mention.js +56 -10
  11. package/src/agent/review.js +192 -0
  12. package/src/agent/session.js +20 -2
  13. package/src/agent/threads.js +1 -1
  14. package/src/backend/adapter.js +342 -280
  15. package/src/backend/azure.js +151 -0
  16. package/src/backend/ctxsize.js +19 -0
  17. package/src/backend/detect.js +106 -0
  18. package/src/backend/http.js +350 -30
  19. package/src/backend/probe.js +48 -1
  20. package/src/backend/proxy.js +151 -0
  21. package/src/backend/quota.js +133 -0
  22. package/src/backend/retry.js +132 -0
  23. package/src/backend/vision.js +185 -0
  24. package/src/commands.js +341 -8
  25. package/src/completion.js +264 -0
  26. package/src/config.js +117 -1
  27. package/src/i18n/en.js +13 -1
  28. package/src/i18n/index.js +22 -3
  29. package/src/i18n/ja.js +266 -0
  30. package/src/i18n/ko.js +12 -0
  31. package/src/i18n/zh.js +266 -0
  32. package/src/oneshot.js +28 -3
  33. package/src/pack/sbom.js +25 -0
  34. package/src/pack/selfpack.js +9 -3
  35. package/src/plugins/manage.js +34 -7
  36. package/src/repl.js +113 -15
  37. package/src/report.js +26 -2
  38. package/src/safety/guard.js +82 -3
  39. package/src/safety/keystore.js +237 -0
  40. package/src/safety/network.js +76 -14
  41. package/src/safety/policy.js +209 -0
  42. package/src/safety/secrets.js +32 -0
  43. package/src/setup.js +40 -7
  44. package/src/tools/clipboard.js +178 -0
  45. package/src/tools/fastgrep.js +217 -0
  46. package/src/tools/fsutil.js +55 -12
  47. package/src/tools/ignore.js +192 -0
  48. package/src/tools/index.js +282 -39
  49. package/src/tools/jobs.js +4 -12
  50. package/src/tools/outline.js +3 -1
  51. package/src/tools/pdf.js +1326 -0
  52. package/src/tools/shell.js +123 -0
  53. package/src/tools/verify.js +20 -4
  54. package/src/tools/webfetch.js +18 -5
  55. package/src/ui/inputbox.js +2 -2
  56. package/src/ui/screen.js +4 -1
  57. package/src/ui/status.js +22 -0
@@ -0,0 +1,123 @@
1
+ // 명령을 어느 셸에서 돌리나. Bash 도구와 Jobs 가 **같은 답**을 봐야 한다 — 두 벌이면 한쪽만 고쳐진다.
2
+ //
3
+ // 윈도우에서는 지금까지 cmd.exe 였고, 프롬프트는 그 말을 한 번도 안 했다. 그래서 모델은
4
+ // ls · cat · grep · rm -rf 를 쳤고, 하나마다 실패 한 번 — 로컬 모델이면 20~40초 — 를 치렀다.
5
+ // 개발자 PC 에는 대개 Git for Windows 가 있고, 그 안의 bash 는 모델이 이미 아는 셸이다.
6
+ //
7
+ // 고르는 차례 (윈도우):
8
+ // 1) DEEL_SHELL 환경변수 · 설정 파일의 shell — auto · bash · powershell · cmd
9
+ // 2) Git Bash — %ProgramFiles%\Git\bin\bash.exe, usr\bin\bash.exe, 또는 PATH 의 bash.exe.
10
+ // 단 System32\bash.exe 는 아니다 — 그건 WSL 을 띄우는 것이라 리눅스로 가 버린다.
11
+ // 3) cmd.exe
12
+ // 파워셸은 시켜야만 쓴다. Windows PowerShell 5.1 은 && 를 모르므로 모델이 자주 넘어진다.
13
+ // 윈도우가 아니면 /bin/sh 그대로다.
14
+ //
15
+ // 고른 것은 세션 안에서 안 바뀐다 — 프롬프트의 `Shell:` 줄이 앞머리(캐시되는 자리)에 있어서다.
16
+ import { existsSync } from 'node:fs';
17
+ import { win32, posix } from 'node:path';
18
+
19
+ const 값들 = ['auto', 'bash', 'powershell', 'cmd'];
20
+
21
+ /** Git Bash 자리 후보. 있는 것 중 첫 것을 쓴다. System32 는 뺀다(WSL 띄우개). */
22
+ export function 배시후보(env = process.env, platform = process.platform) {
23
+ const P = platform === 'win32' ? win32 : posix;
24
+ const 후보 = [];
25
+ for (const k of ['ProgramFiles', 'ProgramW6432', 'ProgramFiles(x86)']) {
26
+ if (!env[k]) continue;
27
+ 후보.push(P.join(env[k], 'Git', 'bin', 'bash.exe'));
28
+ 후보.push(P.join(env[k], 'Git', 'usr', 'bin', 'bash.exe'));
29
+ }
30
+ if (env.LOCALAPPDATA) 후보.push(P.join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe'));
31
+ const 구분 = platform === 'win32' ? ';' : ':';
32
+ for (const dir of String(env.PATH ?? env.Path ?? '').split(구분)) {
33
+ if (!dir.trim() || /[\\/]system32[\\/]?$/i.test(dir.trim())) continue;
34
+ 후보.push(P.join(dir.trim(), 'bash.exe'));
35
+ }
36
+ return [...new Set(후보)];
37
+ }
38
+
39
+ /**
40
+ * 셸을 고른다. 순수 함수 — 검사에서 env · platform · exists 를 바꿔 끼운다.
41
+ * @returns {{ id: 'bash'|'cmd'|'powershell'|'sh', file: string, 명령: (cmd: string) => string[], verbatim: boolean, 표시: string, 경고: string|null }}
42
+ */
43
+ export function 셸고르기({ env = process.env, platform = process.platform, config = null, exists = existsSync } = {}) {
44
+ if (platform !== 'win32') {
45
+ return { id: 'sh', file: '/bin/sh', 명령: (cmd) => ['-c', cmd], verbatim: false, 표시: '/bin/sh', 경고: null };
46
+ }
47
+ let 원하는 = String(env.DEEL_SHELL || config?.shell || 'auto').trim().toLowerCase();
48
+ let 경고 = null;
49
+ if (!값들.includes(원하는)) {
50
+ 경고 = `셸 설정 '${원하는}' 은 모르는 값입니다 — auto 로 갑니다 (auto · bash · powershell · cmd)`;
51
+ 원하는 = 'auto';
52
+ }
53
+ if (원하는 === 'auto' || 원하는 === 'bash') {
54
+ const bash = 배시후보(env, platform).find((p) => { try { return exists(p); } catch { return false; } });
55
+ if (bash) {
56
+ return { id: 'bash', file: bash, 명령: (cmd) => ['-c', cmd], verbatim: false, 표시: `bash (Git for Windows) · ${bash}`, 경고 };
57
+ }
58
+ if (원하는 === 'bash') 경고 = 'bash 를 시켰지만 Git Bash 를 못 찾았습니다 — cmd.exe 로 갑니다';
59
+ }
60
+ if (원하는 === 'powershell') {
61
+ return {
62
+ id: 'powershell', file: 'powershell.exe',
63
+ 명령: (cmd) => ['-NoProfile', '-NonInteractive', '-Command', cmd],
64
+ verbatim: false, 표시: 'Windows PowerShell · powershell.exe', 경고,
65
+ };
66
+ }
67
+ /*
68
+ * cmd.exe 는 명령을 통째로 따옴표로 감싸고, 인자를 손대지 말라고(verbatim) 일러 준다.
69
+ *
70
+ * Node 는 인자를 넘길 때 따옴표를 \" 로 바꿔 주는데 cmd.exe 는 \" 를 모른다. 그래서
71
+ * 따옴표가 든 명령이 통째로 뭉개졌다 — `node -e "console.log(1)"` 이 아무것도 안 하고
72
+ * **종료코드 0** 이었다. 출력도 오류도 없이 '성공' 이라 모델은 잘된 줄 알고 넘어갔다.
73
+ * Node 의 exec() 가 안에서 하는 것과 똑같이 맞춘다. /s 는 그 감싼 따옴표 한 쌍을
74
+ * 벗기라는 뜻이라 짝이 맞는다.
75
+ */
76
+ const file = env.COMSPEC ?? 'cmd.exe';
77
+ return { id: 'cmd', file, 명령: (cmd) => ['/d', '/s', '/c', `"${cmd}"`], verbatim: true, 표시: `cmd.exe · ${file}`, 경고 };
78
+ }
79
+
80
+ let 지금 = null;
81
+
82
+ /** 설정을 읽는 자리(config.load)에서 부른다. 그 뒤로는 정한셸() 이 이것을 준다. */
83
+ export function 셸정하기({ env = process.env, config = null } = {}) {
84
+ 지금 = 셸고르기({ env, config });
85
+ return 지금;
86
+ }
87
+
88
+ export function 정한셸() {
89
+ return 지금 ?? (지금 = 셸고르기());
90
+ }
91
+
92
+ /** 검사용 — 다음 정한셸() 이 다시 고르게. */
93
+ export function 셸지우기() { 지금 = null; }
94
+
95
+ /** 명령 하나를 셸에 넘길 모양. Bash 도구와 Jobs 가 둘 다 이걸 쓴다. */
96
+ export function 셸명령(cmd) {
97
+ const s = 정한셸();
98
+ return { file: s.file, args: s.명령(cmd), verbatim: s.verbatim, id: s.id };
99
+ }
100
+
101
+ /**
102
+ * 모델에게 주는 한 줄. 프롬프트의 굳은 앞머리에 들어간다 — 세션 안에서 변하지 않는다.
103
+ * 모델이 ls 를 칠지 dir 를 칠지가 이 줄에서 갈린다.
104
+ */
105
+ export function 셸안내(영 = false, 셸 = 정한셸()) {
106
+ const os = process.platform === 'win32' ? 'Windows' : process.platform === 'darwin' ? 'macOS' : 'Linux';
107
+ switch (셸.id) {
108
+ case 'bash':
109
+ return 영
110
+ ? '\nShell: bash (Git for Windows) on Windows — Unix commands work (ls, cat, grep, sed, find); Windows tools on PATH (node, git, python) work too. Paths: /c/… or C:\\… are both fine.'
111
+ : '\nShell: bash (Git for Windows), 윈도우 위 — 유닉스 명령(ls · cat · grep · sed · find)이 되고, PATH 의 윈도우 도구(node · git · python)도 그대로 부른다. 경로는 /c/… 꼴과 C:\\… 둘 다 된다.';
112
+ case 'cmd':
113
+ return 영
114
+ ? '\nShell: cmd.exe on Windows — there is no ls/cat/grep/rm. Use dir, type, findstr, del, or wrap with powershell -NoProfile -Command "…". Chain with &&.'
115
+ : '\nShell: cmd.exe, 윈도우 — ls · cat · grep · rm 은 없다. dir · type · findstr · del 을 쓰거나 powershell -NoProfile -Command "…" 로 감싸라. && 로 이을 수 있다.';
116
+ case 'powershell':
117
+ return 영
118
+ ? '\nShell: Windows PowerShell on Windows — use Get-ChildItem, Get-Content, Select-String. There is no && (5.1); chain with ;.'
119
+ : '\nShell: Windows PowerShell, 윈도우 — Get-ChildItem · Get-Content · Select-String 을 쓴다. && 는 없다(5.1) — ; 로 잇는다.';
120
+ default:
121
+ return 영 ? `\nShell: /bin/sh (POSIX) on ${os}.` : `\nShell: /bin/sh (POSIX), ${os}.`;
122
+ }
123
+ }
@@ -31,6 +31,7 @@ import { existsSync, statSync, readFileSync } from 'node:fs';
31
31
  import { execFile } from 'node:child_process';
32
32
  import { extname, dirname, join, resolve } from 'node:path';
33
33
  import { walk, SKIP_DIRS, 내부살림 } from './fsutil.js';
34
+ import { 건너뜀말 } from './ignore.js';
34
35
  import { decode, looksBinary } from './encoding.js';
35
36
  import { checkCommand } from '../safety/guard.js';
36
37
 
@@ -160,17 +161,32 @@ export const VERIFY_TOOL = {
160
161
 
161
162
  // 볼 파일 고르기. 안 주면 작업 폴더에서 확인할 수 있는 것을 찾는다.
162
163
  let 볼것 = [];
164
+ // .gitignore 로 안 본 것은 셈해 뒀다가 끝에 적는다. 조용히 빼면
165
+ // "빌드 산출물에 탈이 있는데 왜 확인이 통과냐" 를 사람이 못 푼다.
166
+ const 건너뜀 = { 폴더: 0, 파일: 0 };
167
+ // 훑기 상한에서 멈춘 것도 같이 챙긴다 — 안 본 것을 "탈 없음" 으로 읽히게 두면 안 된다.
168
+ let 잘림 = false;
169
+ let 상한 = 0;
170
+ const 셈더하기 = (목록) => {
171
+ 건너뜀.폴더 += 목록.건너뜀?.폴더 ?? 0;
172
+ 건너뜀.파일 += 목록.건너뜀?.파일 ?? 0;
173
+ if (목록.잘림) { 잘림 = true; 상한 = 목록.상한 ?? 상한; }
174
+ };
163
175
  if (Array.isArray(args.paths) && args.paths.length) {
164
176
  for (const p of args.paths) {
165
177
  let abs;
166
178
  try { abs = ctx.scope.resolve(p); } catch (e) { 볼것.push({ 없음: p, 왜: e.message }); continue; }
167
179
  if (!existsSync(abs)) { 볼것.push({ 없음: p, 왜: '파일이 없습니다' }); continue; }
168
180
  if (statSync(abs).isDirectory()) {
169
- for (const f of walk(abs, { skipDirs: SKIP_DIRS })) 볼것.push({ path: f.path });
170
- } else 볼것.push({ path: abs });
181
+ const 안것 = walk(abs, { skipDirs: SKIP_DIRS });
182
+ 셈더하기(안것);
183
+ for (const f of 안것) 볼것.push({ path: f.path });
184
+ } else 볼것.push({ path: abs }); // 짚어 준 파일은 규칙과 상관없이 본다
171
185
  }
172
186
  } else {
173
- 볼것 = walk(뿌리, { skipDirs: SKIP_DIRS })
187
+ const 전부 = walk(뿌리, { skipDirs: SKIP_DIRS });
188
+ 셈더하기(전부);
189
+ 볼것 = 전부
174
190
  .filter((f) => ['.html', '.htm', '.css', '.json', '.js', '.mjs', '.cjs', '.py'].includes(extname(f.path).toLowerCase()))
175
191
  .slice(0, 40)
176
192
  .map((f) => ({ path: f.path }));
@@ -273,7 +289,7 @@ export const VERIFY_TOOL = {
273
289
 
274
290
  const 다됐나 = !탈난것.length;
275
291
  return {
276
- content: 줄.join('\n').trim(),
292
+ content: 줄.join('\n').trim() + 건너뜀말(건너뜀, 잘림, 상한),
277
293
  summary: 탈난것.length
278
294
  ? `탈 ${탈난것.length}개 · 확인 ${된것.length}개`
279
295
  : `확인 ${된것.length}개` + (못한것.length ? ` · 못 확인 ${못한것.length}개` : ''),
@@ -12,6 +12,7 @@
12
12
  // · 오프라인이면 아예 거절.
13
13
  // · 받은 것은 글자만 뽑고 길이를 자른다.
14
14
  import { allowTemporarily, isOffline, isLocalHost } from '../safety/network.js';
15
+ import { 원시요청, 몸읽기 } from '../backend/http.js';
15
16
  import { decode as decodeBytes } from './encoding.js';
16
17
  import { 웹글자수 } from '../agent/budget.js';
17
18
 
@@ -150,25 +151,35 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
150
151
  }
151
152
 
152
153
  const close = allowTemporarily(u.origin);
154
+ // 되돌림(redirect)으로 옮겨 간 집도 그 한 번만 연다. 한 홉마다 문지기를 지나므로
155
+ // 여기서 열어 주지 않으면 막힌다 — 그리고 사내망으로 되돌리는 것은 열지 않는다.
156
+ const 열어둔 = [];
153
157
  try {
154
158
  // 같은 집이면 줄을 서고, 잠시 뒤 되는 오류면 쉬었다 다시 부른다.
155
159
  let res = null;
156
160
  let 쉰시간 = 0;
157
161
  for (let 회차 = 0; ; 회차++) {
158
- res = await 한집씩(u.origin, () => fetch(u.href, {
162
+ res = await 한집씩(u.origin, () => 원시요청(u.href, {
159
163
  method: 'GET', // 보내는 건 없다
160
- redirect: 'follow',
161
164
  headers: { 'User-Agent': 'deel/cli', Accept: 'text/html,text/plain,application/json;q=0.9,*/*;q=0.5' },
162
- signal: AbortSignal.timeout(30000),
165
+ timeout: 30000,
166
+ stream: true, // 상한까지만 받는다 — 다 받아 놓고 버리지 않는다
167
+ 되돌림: (다음) => {
168
+ if (다음.protocol !== 'http:' && 다음.protocol !== 'https:') throw new Error(`${다음.protocol} 로 되돌립니다 — 따라가지 않습니다`);
169
+ if (isLocalHost(다음.hostname) && !allowPrivate) throw new Error(`이 컴퓨터·사내망 주소(${다음.hostname})로 되돌립니다 — 따라가지 않습니다`);
170
+ 열어둔.push(allowTemporarily(다음.origin));
171
+ },
163
172
  }));
164
173
  방문기록.push({ url: u.href, status: res.status, at: new Date().toISOString() });
165
174
  if (res.ok || !다시할것.has(res.status) || 회차 >= 다시횟수) break;
175
+ await res.버리기?.();
166
176
  const 초 = 얼마나쉬라나(res.headers.get('retry-after'), 회차);
167
177
  쉰시간 += 초;
168
178
  await 잠깐(초 * 1000);
169
179
  }
170
180
 
171
181
  if (!res.ok) {
182
+ await res.버리기?.();
172
183
  /*
173
184
  * 오류를 그냥 번호로만 던지면 모델은 할 수 있는 게 없다. 실제로 그랬다 —
174
185
  * `✗ HTTP 429` 만 보고 그 자료를 포기했다. 무엇을 하면 되는지 같이 준다.
@@ -193,11 +204,12 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
193
204
 
194
205
  const type = (res.headers.get('content-type') ?? '').toLowerCase();
195
206
  if (!/text|json|xml|javascript/.test(type)) {
207
+ await res.버리기?.();
196
208
  return { error: `글이 아닌 내용입니다 (${type || '알 수 없음'}). 이 도구는 글만 읽습니다.` };
197
209
  }
198
210
 
199
- const buf = Buffer.from(await res.arrayBuffer());
200
- if (buf.length > MAX_BYTES) return { error: `너무 큽니다 (${(buf.length / 1024 / 1024).toFixed(1)}MB).` };
211
+ const buf = await 몸읽기(res.res.body, MAX_BYTES);
212
+ if (!buf) return { error: `너무 큽니다 (${MAX_BYTES / 1024 / 1024}MB 넘음) — 받다 말았습니다. 범위를 좁힌 주소를 쓰세요.` };
201
213
 
202
214
  /*
203
215
  * 무엇으로 쓰여 있는지 알아보고 읽는다.
@@ -274,6 +286,7 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
274
286
  return { error: m };
275
287
  } finally {
276
288
  close(); // 반드시 닫는다. 열어 둔 채로 두면 자물쇠가 아니게 된다.
289
+ for (const 닫기 of 열어둔) 닫기();
277
290
  }
278
291
  }
279
292
 
@@ -35,7 +35,7 @@
35
35
  */
36
36
  import { c, width, cursor, clip, 판시작, 판끝 } from './ansi.js';
37
37
  import { isLocalHost } from '../safety/network.js';
38
- import { statusLine, contextWarning } from './status.js';
38
+ import { statusLine, contextWarning, quotaWarning } from './status.js';
39
39
  import { 접어쓰기 } from './wrap.js';
40
40
  import {
41
41
  켜나 as 사무실켜나, 사무실줄들, 상태만들기 as 사무실상태,
@@ -569,7 +569,7 @@ export class InputBox {
569
569
  커서: 커서자리,
570
570
  폭,
571
571
  상태: this.session ? statusLine(this.session) : '',
572
- 경고: this.session ? (contextWarning(this.session) ?? '') : '',
572
+ 경고: this.session ? (contextWarning(this.session) ?? quotaWarning() ?? '') : (quotaWarning() ?? ''),
573
573
  곁말: this.곁말,
574
574
  일감,
575
575
  추천,
package/src/ui/screen.js CHANGED
@@ -31,7 +31,7 @@
31
31
  * 그대로 맞다. 스크롤·복사·`| tee` 도 그대로다.
32
32
  */
33
33
  import { c, say, cursor, box } from './ansi.js';
34
- import { statusLine, contextWarning } from './status.js';
34
+ import { statusLine, contextWarning, quotaWarning } from './status.js';
35
35
  import { spin } from './spinner.js';
36
36
 
37
37
  /**
@@ -168,6 +168,9 @@ export class LineScreen {
168
168
  say(statusLine(session));
169
169
  const w = contextWarning(session);
170
170
  if (w) say(` ${w}`);
171
+ // 서버 할당량이 바닥나 가면 그것도. 컨텍스트와 다른 이야기라 줄을 따로 쓴다.
172
+ const q = quotaWarning();
173
+ if (q) say(` ${q}`);
171
174
  process.stdout.write(` ${c.hcyan('❯')} `);
172
175
  }
173
176
 
package/src/ui/status.js CHANGED
@@ -3,9 +3,11 @@
3
3
  // 남의 패키지를 붙이지 않는다. 필요한 숫자는 전부 session 이 이미 갖고 있고,
4
4
  // 화면 그리기는 ansi.js 만 쓴다. 반입 심사에 새로 설명할 것이 늘지 않게 하려는 뜻이다.
5
5
  import { c, 눈금게이지, width, clip, cols, mark } from './ansi.js';
6
+ import { 마지막할당량, 할당량말, 아슬아슬한가 } from '../backend/quota.js';
6
7
  import { PROFILES } from '../agent/effort.js';
7
8
  import { get as workMode, canWrite, 보일이름 } from '../agent/modes.js';
8
9
  import { isLocalHost, isOffline } from '../safety/network.js';
10
+ import { 프록시고르기, 프록시설정 } from '../backend/proxy.js';
9
11
  import { COMPACT_AT, FOLD_AT } from '../agent/compact.js';
10
12
  import { 말, 언어 } from '../i18n/index.js';
11
13
  import { 표시 as 승인표시, 고르기 as 승인고르기 } from './approve.js';
@@ -391,6 +393,20 @@ export function contextWarning(session) {
391
393
  return null;
392
394
  }
393
395
 
396
+ /*
397
+ * 서버 할당량이 바닥나 가면 한마디 (backend/quota.js).
398
+ *
399
+ * 넉넉할 때는 아무 말도 안 한다. 늘 떠 있는 줄은 사람이 안 읽게 되고,
400
+ * 안 읽는 줄은 정작 급할 때도 안 읽힌다.
401
+ *
402
+ * 여기서 하는 말은 전부 **서버가 말한 숫자**다. 우리가 짐작한 것이 아니다.
403
+ */
404
+ export function quotaWarning() {
405
+ const 것 = 마지막할당량();
406
+ if (!것 || !아슬아슬한가(것)) return null;
407
+ return `${mark.warn} 서버 할당량 ${할당량말(것)} — 게이트웨이가 알려 준 값입니다.`;
408
+ }
409
+
394
410
  /**
395
411
  * 켤 때 한 번 그리는 머리말 상자 안쪽 줄들.
396
412
  */
@@ -434,6 +450,12 @@ export function headerLines(session, found, 상자쓰나 = true) {
434
450
  const 로컬 = isLocalHost(u.hostname);
435
451
  목적지 = `${로컬 ? c.green(말('head.inside')) : c.yellow(말('head.outside'))} ${c.white(u.host)}`
436
452
  + (isOffline() ? c.green(` ${말('head.offlineLock')}`) : '');
453
+ // 프록시를 거치면 그것도 첫 화면에. "연결 실패" 가 프록시 탓인지는 이 한 마디로 갈린다.
454
+ const 프록시 = 프록시고르기(conn.base);
455
+ if (프록시) 목적지 += c.gray(` ${말('head.viaProxy', { 프록시: `${프록시.host}:${프록시.port}` })}`);
456
+ // 적어 놨는데 못 쓰는 프록시(socks5 등)는 여기서 한 번은 말해야 한다 — 조용히 직접 가면
457
+ // 사람은 프록시를 탄 줄 알고, 막히면 엉뚱한 데를 고친다.
458
+ else if (프록시설정().탈) 목적지 += c.yellow(` ${말('head.proxyBad')}`);
437
459
  } catch { 목적지 = c.gray(String(conn.base)); }
438
460
 
439
461
  /*