deel-local-cli 1.0.2 → 1.1.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/src/repl.js CHANGED
@@ -1,887 +1,1074 @@
1
- // 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
2
- import { createInterface, emitKeypressEvents } from 'node:readline';
3
- import { homedir } from 'node:os';
4
- import { resolve } from 'node:path';
5
- import { c, say as 바로쓰기, mark, clip } from './ui/ansi.js';
6
- import { headerLines } from './ui/status.js';
7
- import { 화면고르기 } from './ui/screen.js';
8
- import { STAGES } from './agent/effort.js';
9
- import { handle, COMMANDS } from './commands.js';
10
- import { next as nextWork, get as getWork, canWrite } from './agent/modes.js';
11
- import { route } from './agent/route.js';
12
- import { run } from './agent/loop.js';
13
- import { Session } from './agent/session.js';
14
- import { makeScope } from './safety/guard.js';
15
- import { History } from './safety/undo.js';
16
- import { Audit } from './safety/audit.js';
17
- import { activeProfile, load, resolveKey, save as saveCfg } from './config.js';
18
- import { discover } from './skills/discover.js';
19
- import { allowEndpoint, setOffline, isOffline, isLocalHost } from './safety/network.js';
20
- import { Store, latest, prune } from './agent/store.js';
21
- import { askHidden } from './ui/prompt.js';
22
- import { explain, shows as levelShows } from './ui/level.js';
23
- import { 고르기 as 승인고르기, 다음 as 승인다음 } from './ui/approve.js';
24
- import { 추천, 채울글 } from './ui/complete.js';
25
- import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
26
- import { renderDiff, shortStat } from './ui/diff.js';
27
- import { expand as expandMentions } from './agent/mention.js';
28
- import { 다붙이기 } from './backend/mcp.js';
29
- import { 프롬프트토막 as 기억토막, 읽기 as 기억읽기 } from './agent/memory.js';
30
- import { 갈래고르기 } from './ui/working.js';
31
-
32
- // 도구마다 눈에 띄는 글자를 다르게 준다. 훑을 때 종류가 먼저 보인다.
33
- const TOOL_GLYPH = {
34
- Read: c.blue('◧'),
35
- Write: c.green(''),
36
- Edit: c.yellow(''),
37
- Glob: c.magenta(''),
38
- Grep: c.magenta(''),
39
- Bash: c.hcyan(''),
40
- Skill: c.hmagenta(''),
41
- WebFetch: c.hblue(''),
42
- TodoWrite: c.hyellow(''),
43
- };
44
-
45
- // 도구 호출을 줄로 요약 Read(src/a.js) 처럼.
46
- function toolLabel(name, args) {
47
- const a = args ?? {};
48
- const first =
49
- a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ??
50
- (a.command ? String(a.command).replace(/\s+/g, ' ').slice(0, 52) : null) ??
51
- // 목록은 보여줄 경로가 없다. 빈 괄호를 띄우느니 개수를 적는다.
52
- (Array.isArray(a.todos) ? `${a.todos.length}건` : null) ?? '';
53
- const g = TOOL_GLYPH[name] ?? c.cyan('⏺');
54
- const = clip(String(first ?? ''), 56);
55
- return `${g} ${c.bold(name)}${안 ? `${c.gray('(')}${c.gray(안)}${c.gray(')')}` : ''}`;
56
- }
57
-
58
- function toolResultLine(result, ms) {
59
- const t = ms > 700 ? c.gray(` ${(ms / 1000).toFixed(1)}초`) : '';
60
- if (result?.error) return `${c.red('└')} ${c.red(clip(String(result.error).split('\n')[0], 80))}${t}`;
61
- // 고친 자리는 줄이 늘고 줄었는지를 요약 옆에 붙인다.
62
- const = result?.diff ? ` ${shortStat(result.diff)}` : '';
63
- return `${c.gray('└')} ${c.gray(clip(result?.summary ?? '완료', 80))}${셈}${t}`;
64
- }
65
-
66
- // 수준별로 몇 줄까지 펼칠지. 초보에게 60줄을 쏟으면 아무것도 안 읽는다.
67
- const DIFF_LINES = { 쉬움: 14, 개발자: 40 };
68
-
69
- // 모델의 앞에 세우는 세로줄. 도구 줄과 눈으로 갈리게 하는 유일한 표시다.
70
- const 답표시 = c.hcyan('');
71
-
72
- export async function chatLoop(opts = {}) {
73
- const cfg = load();
74
- const prof = activeProfile(cfg);
75
- // 연결이 없으면 화면을 세우기 전에 끝난다. 세운 뒤에 나가면 상자를
76
- // 그렸다 지우는 제어문자가 안내 사이에 끼어 화면이 지저분해진다.
77
- if (!prof) {
78
- 바로쓰기('');
79
- 바로쓰기(` ${mark.warn} 저장된 연결이 없습니다. ${c.cyan('deel setup')} 먼저 실행하세요.`);
80
- 바로쓰기('');
81
- return 1;
82
- }
83
-
84
- /*
85
- * 여기서부터 화면에 나가는 것은 전부 `화면` 을 거친다.
86
- *
87
- * `say` 지역 이름으로 다시 묶은 이유: 이 함수 안에 출력이 79군데 있었다.
88
- * 이름을 60번 바꿔 적으면 그중 한둘은 반드시 어긋나고, 어긋난 자리는
89
- * 화면에서만 티가 난다. 이름은 그대로 두고 **가는 곳만** 바꾼다.
90
- * 그러면 갈라내기가 옳은지를 지금 있는 검사들이 그대로 준다
91
- * 글자 하나라도 달라지면 검사가 잡는다.
92
- *
93
- * 파일 밖으로 나가는 이름이 아니다. 화면을 세우기 전에 쓰는 자리는
94
- * 위처럼 `바로쓰기` 를 쓴다.
95
- */
96
- const 화면 = await 화면고르기({ tui: opts.tui });
97
- const say = (s = '') => 화면.줄(s);
98
-
99
- const root = opts.root ? opts.root : process.cwd();
100
- const conn = {
101
- kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
102
- key: resolveKey(prof), model: prof.model,
103
- // 컨텍스트 길이. 순서가 우선순위다
104
- // deel --ctx 655360 > 프로필에 저장된 값 > 기본값
105
- // 기본값으로 떨어졌다는 것은 '아직 못 쟀다' 는 뜻이다. 아래에서 그렇다고 말해 준다.
106
- ctx: opts.ctx ?? prof.ctx ?? CTX_DEFAULT,
107
- // 길이 상한. 컨텍스트와 다른 축이다 없으면 effort.js 의 울타리를 쓴다.
108
- // deel --max-tokens 32k > 프로필에 저장된 값 > 서버에서 알아낸 값 > 기본값
109
- maxTokens: opts.maxTokens ?? prof.maxTokens ?? null,
110
- streaming: prof.streaming ?? false,
111
- tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
112
- };
113
-
114
- // 자리 하나만 연다. 다른 어디로도 나가지 못한다.
115
- allowEndpoint(conn.base);
116
- if (opts.offline ?? prof.offline) setOffline(true);
117
-
118
- const session = new Session(conn, {
119
- root,
120
- mode: opts.mode ?? 'auto',
121
- // 처음부터 원하는 모드로 시작할 수 있다 — deel --work plan
122
- work: opts.work ?? null,
123
- // 수준은 설정에 남는다. 한 번 고르면 다음에 켤 때도 그대로다.
124
- level: opts.level ?? cfg.level ?? null,
125
- think: opts.think ?? 'medium',
126
- effort: opts.effort ?? 'save',
127
- maxSteps: opts.maxSteps ?? 24,
128
- });
129
-
130
- // ── 대화 이어하기 ─────────────────────────────────────────────────────
131
- // 껐다 켜도 이어지도록, 메시지가 오갈 때마다 .deel/sessions/ 바로 적는다.
132
- let store = null;
133
- if (opts.sessionId || opts.continue) {
134
- const target = opts.sessionId ?? latest(root)?.id;
135
- if (!target) {
136
- say('');
137
- say(` ${c.gray('이어할 대화가 없습니다. 새로 시작합니다.')}`);
138
- } else {
139
- store = new Store(root, target);
140
- const { messages } = store.load();
141
- if (messages.length) {
142
- session.messages = messages;
143
- say('');
144
- say(` ${mark.ok} ${c.bold(target)} ${c.gray(`— 메시지 ${messages.length}개를 이어 받았습니다.`)}`);
145
- }
146
- }
147
- }
148
- if (!store) store = new Store(root);
149
- store.begin({ model: conn.model, base: conn.base, root });
150
- try { prune(root); } catch {}
151
-
152
- /*
153
- * 밖에서 붙인 도구(MCP) 서버를 띄운다.
154
- *
155
- * 기본은 꺼져 있다 .deel/mcp.json 사람이 직접 적어야만 뜬다. 남의
156
- * 프로그램을 띄우는 일이라, 이 프로젝트가 존재하는 이유('미승인 SW 반입 금지')
157
- * 정면으로 부딪히기 때문이다. 자물쇠(--offline)가 걸려 있으면 아예 안 띄운다.
158
- */
159
- const mcp붙임 = await 다붙이기(root, {
160
- offline: isOffline(),
161
- audit: new Audit(root),
162
- });
163
-
164
- // PC 있는 스킬·명령·플러그인을 찾아 붙인다. 품고 다니지 않는다.
165
- const found = discover(root);
166
- session.skills = found.skills;
167
- session.commands = found.commands;
168
- session.plugins = found.plugins;
169
- // 세션도 알아야 한다 — 밖에서 붙인 도구도 스키마가 매 요청에 실린다.
170
- // 안 세면 컨텍스트가 그만큼 조용히 줄어든다.
171
- session.mcp = mcp붙임.서버들;
172
- // 지난 대화에서 정해 것을 들고 시작한다.
173
- session.memory = 기억토막(root);
174
-
175
- /*
176
- * 입력 상자를 쓸 때는 readline 이 스스로 되비추지 못하게 한다.
177
- *
178
- * readline 자기가 아는 커서 자리를 기준으로 지우고 다시 그린다. 그런데
179
- * 자리는 우리가 그린 상자 테두리 안이 아니라 줄 맨 앞이다. 그대로 두면
180
- * 백스페이스가 테두리를 갉아먹고, 긴 글이 접힐 때 상자가 무너진다.
181
- *
182
- * 그래서 되비추는 일만 뺏는다. 어디까지나 **되비추기만** 이다 — 한글 조합,
183
- * 붙여넣기, 위아래 이력, Ctrl+A/E, 백스페이스는 전부 readline 이 그대로
184
- * 맡는다. 우리는 readline 이 들고 있는 글(rl.line)을 상자 안에 그릴 뿐이다.
185
- * 편집을 직접 짜기 시작하면 한글 입력기부터 깨진다.
186
- */
187
- const 상자쓰나 = 화면.kind === 'box';
188
- // 사람이 지금 입력을 기다리는 중인가. 도구가 도는 동안 키를 눌러도
189
- // 상자를 다시 그리면 된다 — 그 자리는 이미 대화가 흘러가고 있다.
190
- let 입력기다림 = false;
191
- // 되묻는 중이면 앞머리. 상자 대신 한 줄로 되비춘다.
192
- let 묻는중 = null;
193
- // 이번 틱에 다시 그리기로 이미 잡아 뒀나 (붙여넣기로 키가 쏟아질 때)
194
- let 그릴예정 = false;
195
- const 먹통 = { write() { return true; }, end() {}, on() {}, once() {}, emit() {}, removeListener() {} };
196
- const rl = createInterface({
197
- input: process.stdin,
198
- output: 상자쓰나 ? 먹통 : process.stdout,
199
- terminal: 상자쓰나 ? true : undefined,
200
- historySize: 200,
201
- /*
202
- * 완성기. 아무것도 안 내놓지만 **달아 둬야** 한다.
203
- *
204
- * 네 가지를 실제로 눌러 보고 정했다.
205
- *
206
- * 완성기 없음 Tab 줄에 리터럴 탭이 박힌다 (`/hel` + Tab → `/hel\t`).
207
- * 그 글이 그대로 모델에게 간다. 검사 6개가 여기서 빨개진다.
208
- * 진짜 완성기 readline 이 자기 방식으로 줄을 고쳐 버린다. 우리가 그린
209
- * 상자와 어긋나고, 후보가 있는 줄에서 Shift+Tab 을 누르면
210
- * 승인 방식만 바뀌어야 하는데 글까지 바뀐다.
211
- * 빈 완성기 Tab Shift+Tab 줄을 안 건드린다. ← 이것
212
- *
213
- * 그래서 readline 에게서는 '줄을 건드림' 만 받고, 무엇을 채울지는 우리가
214
- * rl.write 직접 정한다. rl.write 공개 API 한글도 깨진다.
215
- */
216
- completer: 상자쓰나 ? (line) => [[], line] : undefined,
217
- });
218
-
219
- // 입력을 큐로 받는다. rl.question 겹쳐 쓰면 파이프로 넣을 닫혀 버린다.
220
- const queue = [];
221
- let waiter = null;
222
- let closed = false;
223
- const echo = !process.stdin.isTTY; // 파이프·기록용일 때는 입력을 되비춘다
224
-
225
- rl.on('line', (l) => {
226
- if (echo) say(c.gray(l));
227
- if (상자쓰나) {
228
- if (묻는중 !== null) {
229
- // 되묻는 자리: 답을 줄에 남긴 줄만 넘긴다.
230
- process.stdout.write(`\r\x1b[2K${묻는중}${c.white(l)}\n`);
231
- } else {
232
- // 상자를 걷어내고, 사람이 보낸 글을 대화에 남긴다. 남기면 스크롤을
233
- // 올렸을 답만 있고 무엇을 물었는지가 없다.
234
- 화면.입력지움();
235
- if (l.trim()) say(` ${c.hcyan('❯')} ${c.white(l)}`);
236
- }
237
- }
238
- if (waiter) { const w = waiter; waiter = null; w(l); }
239
- else queue.push(l);
240
- });
241
- rl.on('close', () => {
242
- closed = true;
243
- if (waiter) { const w = waiter; waiter = null; w(null); }
244
- });
245
-
246
- /*
247
- * 지금 치고 있는 글에 맞는 명령들.
248
- *
249
- * 수준에 따라 감춘 명령이 있다(쉬움에서는 자주 쓰는 것만 보인다). 그런데
250
- * **감춘 것이 쓰는 것은 아니다** — 치면 그대로 돌아간다. 그래서 보이는
251
- * 중에 맞는 없으면 감춘 것까지 뒤진다. `/recall` 을 아는 사람이
252
- * 쉬움 수준이라는 이유로 "그런 명령 없다" 화면을 보면 안 된다.
253
- */
254
- const 지금추천 = (글) => {
255
- const 보이는것 = Object.keys(COMMANDS).filter((n) => n !== 'quit' && levelShows(session.level, n));
256
- const 것 = 추천(글, COMMANDS, 보이는것);
257
- if (것.length) return 것;
258
- return 추천(글, COMMANDS, Object.keys(COMMANDS).filter((n) => n !== 'quit'));
259
- };
260
-
261
- /*
262
- * 키를 가로챈다 — 터미널일 때만.
263
- *
264
- * 파이프로 넣을 가로채면 입력이 깨진다. 검사와 데모가 그렇게 돌아간다.
265
- *
266
- * ── Shift+Tab 은 무엇을 돌려야 하나 ──────────────────────────────────
267
- *
268
- * 전에는 **작업 모드**(종합/코드/계획…) 돌렸다. 바꾼다. Shift+Tab 은
269
- * **승인 방식**을 돌린다 — 안 묻고 고칠지, 매번 물을지.
270
- *
271
- * 두 가지가 이 자리를 놓고 다퉜는데, 자주 눌러야 하는 쪽이 이겨야 한다.
272
- * 작업 모드는 요청을 보고 저절로 옮겨 가므로 사람이 손댈 일이 드물다.
273
- * 반면 승인 방식은 "이번 봐야겠다" 싶을 **일하는 도중에** 바꾸고
274
- * 싶어진다. 그리고 이건 안전 설정이라, 손이 기억하는 자리에 있어야 한다.
275
- * 다른 도구(Claude Code)도 같은 키에 같은 것을 둔다.
276
- *
277
- * 작업 모드는 Ctrl+O 로 옮겼다. `/work` 도 그대로 된다.
278
- */
279
- if (process.stdin.isTTY) {
280
- emitKeypressEvents(process.stdin, rl);
281
- process.stdin.on('keypress', (_ch, key) => {
282
- // Shift+Tab 승인 방식 (자동 위험만 모두)
283
- if (key && key.name === 'tab' && key.shift) {
284
- const 앞 = 승인고르기(session.mode);
285
- session.mode = 승인다음(session.mode);
286
- const 뒤 = 승인고르기(session.mode);
287
- 화면.입력지움();
288
- say(` ${뒤.색(뒤.글자)} ${c.bold(뒤.색(뒤.이름))} ${c.gray(뒤.한줄)}`);
289
- say(` ${c.gray(`${앞.이름} → ${뒤.이름} · Shift+Tab 으로 계속 바꿉니다`)}`);
290
- prompt();
291
- return;
292
- }
293
- // Ctrl+O 작업 모드 (종합/코드/계획/설계/디버그/묻기/총괄)
294
- if (key && key.ctrl && key.name === 'o') {
295
- session.work = nextWork(session.work);
296
- const w = getWork(session.work);
297
- 화면.입력지움();
298
- say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')} ${c.gray(w.hint)}`
299
- + (canWrite(session.work) ? '' : ` ${c.green( 파일을 바꿉니다')}`));
300
- prompt();
301
- return;
302
- }
303
- /*
304
- * Tab 치던 슬래시 명령을 채운다.
305
- *
306
- * 하나만 맞으면 끝까지, 여럿이면 다 같이 가진 앞부분까지. 목록에서
307
- * 위아래로 고르게 하지 않는다 — 그러면 지난 입력 이력(위 화살표)을
308
- * 뺏어야 하는데, 그건 훨씬 자주 쓰는 기능이다.
309
- */
310
- if (key && key.name === 'tab' && !key.shift && 상자쓰나 && 입력기다림 && 묻는중 === null) {
311
- const 채울 = 채울글(rl.line ?? '', 지금추천(rl.line ?? ''));
312
- if (채울) rl.write(채울);
313
- // 채울 게 없어도 그리기는 한다 — 후보 목록이 그대로 남아 있어야 한다.
314
- 화면.입력갱신(session, rl.line ?? '', rl.cursor ?? 0, 지금추천(rl.line ?? ''));
315
- return;
316
- }
317
- /*
318
- * 것을 상자 안에 그린다.
319
- *
320
- * readline 이 이 키를 처리하고 rl.line 을 고친 **뒤에** 그려야 하는데,
321
- * keypress 전에 온다. 그래서 미룬다. 미루면 늘 한 글자
322
- * 뒤처진 글이 보인다 치는 사람 눈에는 마지막 글자가 찍히는 것으로
323
- * 보이고, 그게 제일 미더운 화면이다.
324
- */
325
- if (!상자쓰나) return;
326
- if (key && key.name === 'return') return; // 줄이 끝나는 것은 'line' 이 맡는다
327
- if (묻는중 !== null) {
328
- const 앞 = 묻는중;
329
- setImmediate(() => {
330
- if (묻는중 === null) return;
331
- process.stdout.write(`\r\x1b[2K${앞}${c.white(rl.line ?? '')}`);
332
- });
333
- return;
334
- }
335
- if (!입력기다림) return;
336
- /*
337
- * 여러 키가 한꺼번에 들어와도 **한 번만** 그린다.
338
- *
339
- * 붙여넣기는 글자 수만큼 키가 쏟아진다. 스무 줄짜리를 붙이면 상자를
340
- * 수백 번 다시 그리게 되고, 화면이 눈에 띄게 떨린다. 어차피 마지막
341
- * 한 번이 지금 상태이므로, 이번 틱에 이미 잡아 뒀으면 그냥 넘긴다.
342
- */
343
- if (그릴예정) return;
344
- 그릴예정 = true;
345
- setImmediate(() => {
346
- 그릴예정 = false;
347
- if (입력기다림) 화면.입력갱신(session, rl.line ?? '', rl.cursor ?? 0, 지금추천(rl.line ?? ''));
348
- });
349
- });
350
- }
351
-
352
- const nextLine = () => {
353
- if (queue.length) return Promise.resolve(queue.shift());
354
- if (closed) return Promise.resolve(null);
355
- return new Promise((res) => { waiter = res; });
356
- };
357
-
358
- const ask = async (label, o = {}) => {
359
- const 앞 = ` ${c.gray('›')} ${label} ${o.def ? c.gray(`[${o.def}] `) : ''}`;
360
- 화면.붙임();
361
- /*
362
- * 되묻는 자리는 상자를 안 쓴다 — '실행할까요? (y/n)' 에 테두리를 두르면
363
- * 대화의 흐름이 끊긴다. 대신 되비추는 일은 우리가 맡아야 한다.
364
- * 상자 모드에서는 readline 되비추기를 놨기 때문이다. 안 해 주면
365
- * y 를 쳐도 화면에 아무것도 안 나타난다 — 먹은 건지 안 먹은 건지 모른다.
366
- */
367
- 묻는중 = 상자쓰나 ? 앞 : null;
368
- try {
369
- const a = await nextLine();
370
- if (a === null) return o.def ?? '';
371
- return a.trim() || o.def || '';
372
- } finally { 묻는중 = null; }
373
- };
374
-
375
- /**
376
- * 오류를 사람 수준에 맞게 보여준다.
377
- *
378
- * 쉬움 수준에서는 무엇을 하면 되는지를 앞에 놓고, 원래 문구는 회색으로 뒤에 남긴다.
379
- * 원인을 지우지 않는 것이 중요하다 — 지우면 물어볼 수도 없게 된다.
380
- * 개발자 수준에서는 원래 문구 그대로다.
381
- */
382
- const 오류보이기 = (message) => {
383
- const r = explain(session.level, message);
384
- if (!r.plain) { say(` ${c.red('✗')} ${String(message)}`); return; }
385
- const [머리, ...나머지] = r.text.split('\n');
386
- say(` ${c.red('✗')} ${머리}`);
387
- for (const l of 나머지) say(` ${l}`);
388
- if (r.detail) say(` ${c.gray(`(원래 문구: ${clip(String(r.detail).split('\n')[0], 90)})`)}`);
389
- };
390
-
391
- /**
392
- * 이번 턴에 만들어진 파일을 **디스크를 보고** 말해 준다.
393
- *
394
- * 모델이 "만들었습니다" 라고 답을 맺어도 그건 모델의 말이다. 도구가 실패했는데
395
- * 그렇게 맺는 경우가 실제로 있다. 사용자는 그 말을 믿고 다음 일로 넘어가고,
396
- * 한참 뒤에야 파일이 없다는 걸 안다. 그때는 대화가 이미 흘러가 있다.
397
- *
398
- * 그래서 말 대신 파일을 본다. 몇 줄인지 · 몇 KB 인지까지 적는다 —
399
- * '만들어지긴 했는데 반쪽' 인 경우를 그 숫자로 바로 알아볼 수 있다.
400
- */
401
- const 만든파일보이기 = (files) => {
402
- if (!Array.isArray(files) || !files.length) return;
403
- say('');
404
- for (const f of files) {
405
- const 이름 = ctx?.scope ? ctx.scope.show(f.path) : f.path;
406
- if (f.missing) {
407
- say(` ${c.yellow('⚠')} ${c.white(이름)} ${c.gray('— 만들어지지 않았습니다')}`);
408
- continue;
409
- }
410
- if (f.dir) continue;
411
- const kb = f.bytes >= 1024 ? `${(f.bytes / 1024).toFixed(1)}KB` : `${f.bytes}B`;
412
- say(` ${c.green('✓')} ${c.white(이름)} ${c.gray(`· ${f.lines.toLocaleString()}줄 · ${kb}`)}`);
413
- }
414
- };
415
-
416
- const ctx = {
417
- scope: makeScope(root),
418
- history: new History(root),
419
- audit: new Audit(root),
420
- seen: new Set(),
421
- // 붙은 MCP 서버. 도구를 부를 때 여기서 찾는다.
422
- mcp: mcp붙임.서버들,
423
- skills: found.skills,
424
- loadedSkills: new Set(),
425
- ask,
426
- // 암호는 여기서만 받는다. 받은 값은 도구가 쓰고 버린다
427
- // 설정에도, 세션 기록에도, 감사기록에도, 명령줄에도 남는다.
428
- askPassword: async (label) => {
429
- if (closed) return null;
430
- const pw = await askHidden(rl, label, nextLine);
431
- return pw === null || pw === '' ? null : pw;
432
- },
433
- confirm: async (name, args) => {
434
- say('');
435
- say(` ${c.yellow('?')} ${toolLabel(name, args)}`);
436
- const a = (await ask('실행할까요? (y/n)', { def: 'y' })).toLowerCase();
437
- return a === 'y' || a === 'yes' || a === 'ㅇ';
438
- },
439
- };
440
-
441
- // ── 컨텍스트 길이를 모델에서 긁어온다 ─────────────────────────────────
442
- //
443
- // 때마다 서버에 물어본다. 저장된 값을 그대로 믿지 않는다
444
- // 같은 이름의 모델이라도 서버에서 몇 k 로 올렸는지가 그때그때 다르고,
445
- // 그 차이를 화면에 못 보면 조용히 작아진 채로 쓰게 된다.
446
- //
447
- // --ctx 직접 주신 값이 있으면 안 건드린다. 사람이 고른 것을 뒤집지 않는다.
448
- const 길이알림 = []; // 된 소식
449
- const 길이경고 = []; // 손을 봐야 하는 것
450
- if (opts.ctx == null) {
451
- // 컴퓨터 안의 서버면 눈 깜짝할 새다. 사내 게이트웨이는 몇 초 걸릴 수 있어
452
- // 무슨 일이 일어나는 중인지 알려 준다 — 멈춘 것처럼 보이면 안 된다.
453
- 화면.돌리기('모델에 걸린 컨텍스트 길이를 확인하는 중…');
454
- let r = null;
455
- try { r = await probeCtx(conn, { timeout: 6000 }); } catch { /* 못 물어보면 아래에서 처리 */ }
456
- 화면.돌림멈춤('');
457
- if (r?.value) {
458
- const = conn.ctx;
459
- conn.ctx = r.value;
460
- // 알아낸 값은 프로필에 남긴다. 다음에 화면이 곧바로 맞게 뜬다.
461
- if (prof.ctx !== r.value) {
462
- prof.ctx = r.value;
463
- try { const cfg2 = load(); const t = cfg2.profiles.find((p) => p.id === prof.id); if (t) { t.ctx = r.value; saveCfg(cfg2); } } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
464
- }
465
- if (전 !== r.value) 길이알림.push(`컨텍스트를 ${전.toLocaleString()} ${c.gray('→')} ${c.white(r.value.toLocaleString())} 로 맞췄습니다 ${c.gray('(' + (r.source ?? '서버') + '에서 읽음)')}`);
466
- if (r.max && r.loaded && r.max > r.loaded) {
467
- 길이경고.push(`이 모델은 ${c.white(r.max.toLocaleString())} 까지 됩니다 — 서버에서 더 올린 뒤 ${c.cyan('/ctx auto')}`);
468
- }
469
- } else if (prof.ctx == null) {
470
- 길이경고.push(`컨텍스트를 서버가 안 알려줍니다 — 우선 ${CTX_DEFAULT.toLocaleString()} 으로 잡았습니다. ${c.cyan('/ctx 655360')} 처럼 직접 지정하세요`);
471
- }
472
- }
473
-
474
- // ── 머리말 ────────────────────────────────────────────────────────────
475
- 화면.머리말(headerLines(session, found));
476
- const warn = [];
477
- // 홈 폴더에서 켠 경우. 작업 범위가 집 전체가 된다.
478
- //
479
- // 실제로 이렇게 켠 화면을 봤다. 그러면 Glob 이 홈 전체를 훑어 느려지고,
480
- // 모델이 ~/.deel, ~/.claude, ~/package.json 같은 상관없는 것부터 읽는다.
481
- // 막혀 있어서 안전하긴 하지만, 애초에 여기서 켤 일이 아니다.
482
- // 윈도우는 같은 폴더라도 대소문자가 다르게 있다. 맞춰서 견준다.
483
- const 같은폴더 = (a, b) => {
484
- const n = (p) => resolve(String(p ?? '')).replace(/[\\/]+$/, '');
485
- return process.platform === 'win32'
486
- ? n(a).toLowerCase() === n(b).toLowerCase()
487
- : n(a) === n(b);
488
- };
489
- if (같은폴더(root, homedir())) {
490
- warn.push('홈 폴더에서 켰습니다 — 작업 범위가 집 전체입니다. 일할 폴더로 옮겨 다시 켜는 편이 빠르고 안전합니다');
491
- }
492
- // 밖에서 붙인 도구는 **붙었다고 반드시 말한다.** 남의 프로그램이 이 컴퓨터에서
493
- // 돌고 있다는 사실을 조용히 넘기면 안 된다 — 그게 이 도구가 심사를 통과한 근거다.
494
- // 기억을 들고 시작한다는 것을 반드시 말한다. 조용히 실으면 사람은 왜 모델이
495
- // 알려준 것을 아는지 모른다 — 그게 불안하다.
496
- {
497
- const 기억 = 기억읽기(root);
498
- if (기억.줄들.length) 길이알림.push(`지난 대화에서 정한 ${기억.줄들.length}개를 들고 시작합니다 ${c.cyan('/memory')}`);
499
- }
500
- if (mcp붙임.서버들.length) {
501
- const 도구수 = mcp붙임.서버들.reduce((n, s) => n + s.도구.length, 0);
502
- 길이알림.push(`밖에서 붙인 도구 ${도구수}개 ${c.gray(`(서버 ${mcp붙임.서버들.length}대: ${mcp붙임.서버들.map((s) => s.이름).join(' · ')})`)} ${c.cyan('/mcp')}`);
503
- }
504
- // 것은 조용히 빠지면 된다. "왜 도구가 없지" 를 영영 알 수 없다.
505
- for (const m of mcp붙임.못한것) warn.push(`MCP ${c.white(m.이름)} 을 못 붙였습니다 — ${m.왜}`);
506
- if (!conn.tools) warn.push('도구 호출이 확인되지 않았습니다 — deel diagnose 로 점검하세요');
507
- if (!conn.streaming) warn.push('스트리밍이 없어 응답이 한 번에 나옵니다');
508
- warn.push(...길이경고);
509
- // 잘 된 것은 경고 표시를 달지 않는다. ⚠ 가 붙으면 뭘 고쳐야 하나 싶어진다.
510
- for (const l of 길이알림) say(` ${mark.ok} ${c.gray(l)}`);
511
- for (const w of warn) say(` ${mark.warn} ${c.gray(w)}`);
512
- say(` ${c.gray('/help 명령 목록')} ${c.gray('/think 추론 강도')} ${c.gray('Ctrl+C 중단·끝내기')}`);
513
-
514
- /*
515
- * 입력 자리. 어떻게 생겼는지는 화면 쪽이 정한다 —
516
- * 줄화면은 상태줄을 깔고 아래 를, 상자화면은 테두리를 두른 칸을 그린다.
517
- *
518
- * 치던 글은 되살린다. Shift+Tab 이나 Ctrl+C 처럼 **입력 도중에** 줄을
519
- * 끼워 넣고 다시 그리는 자리가 있는데, 그때 칸을 그리면 치던 글이
520
- * 사라진 것처럼 보인다. 실제로는 readline 이 그대로 들고 있어서 Enter 를
521
- * 치면 멀쩡히 보내진다 — 화면만 거짓말을 하는 셈이라 더 나쁘다.
522
- */
523
- const prompt = () => {
524
- const 글 = 상자쓰나 && 입력기다림 ? (rl.line ?? '') : '';
525
- 화면.입력자리(session, 글, ? (rl.cursor ?? 0) : 0, 글 ? 지금추천(글) : []);
526
- };
527
-
528
- // Ctrl+C 상황에 따라 뜻이 다르다.
529
- // 모델이 답하는 중 → 그 답을 끊는다 (프로그램은 살아 있다)
530
- // 입력을 기다리는 번은 경고, 번이면 끝낸다
531
- // 느린 로컬 모델이 엉뚱한 답을 길게 뽑기 시작했을 때 끝까지 기다리지 않아도 된다.
532
- let interrupted = false;
533
- let turn = null; // 지금 도는 턴의 AbortController
534
- rl.on('SIGINT', () => {
535
- if (turn && !turn.signal.aborted) {
536
- turn.abort();
537
- return; // 화면 정리는 루프 'aborted' 이벤트가 한다
538
- }
539
- if (interrupted) { rl.close(); return; }
540
- interrupted = true;
541
- say('');
542
- say(` ${c.gray('한 Ctrl+C 누르면 끝냅니다.')}`);
543
- prompt();
544
- });
545
-
546
- for (;;) {
547
- prompt();
548
- 입력기다림 = true;
549
- const line = await nextLine();
550
- 입력기다림 = false;
551
- if (line === null) break; // 입력이 끝났다 (파이프 종료 / Ctrl+D)
552
- interrupted = false;
553
- const text = line.trim();
554
- if (!text) continue;
555
-
556
- const cmd = await handle(text, session, ctx);
557
- if (cmd.exit) break;
558
- if (cmd.handled) continue;
559
- const toSend = cmd.text ?? text; // 슬래시 명령이면 펼쳐진 내용을 보낸다
560
-
561
- // @파일 지목했으면 파일을 바로 붙여 보낸다.
562
- //
563
- // 붙인 것은 화면에 반드시 알린다. 사람이 보낸 줄 아는 글이 대화에
564
- // 들어가 있으면 된다 컨텍스트가 줄었는지도 모르게 된다.
565
- let 보낼글 = toSend;
566
- if (toSend.includes('@')) {
567
- const 예산 = Math.min(20000, Math.floor((session.conn.ctx ?? CTX_DEFAULT) * 0.25));
568
- const r = expandMentions(toSend, {
569
- scope: ctx.scope, budget: 예산, seen: ctx.seen,
570
- onRead: (p, t) => session.noteRead(p, t),
571
- });
572
- 보낼글 = r.text;
573
- for (const a of r.attached) {
574
- say(` ${c.blue('◧')} ${c.gray('붙임')} ${c.white(a.show)}${a.full ? '' : c.gray(' (앞부분만)')}`);
575
- }
576
- for (const b of r.blocked) {
577
- say(` ${mark.warn} ${c.gray(`${b.path} 작업 범위 밖이라 붙였습니다.`)}`);
578
- }
579
- }
580
-
581
- // 종합 모드면 이 한마디가 무슨 일인지 보고 알맞은 모드로 옮긴다.
582
- //
583
- // 기본 모드는 건드린다 다음 한마디는 다시 처음부터 고른다.
584
- // 사용자가 직접 고른 모드가 있으면 여기 들어온다. 사람이 고른 것을 뒤집지 않는다.
585
- session.routed = null;
586
- if (session.work === 'auto') {
587
- const 골라진 = route(toSend);
588
- if (골라진.mode) {
589
- session.routed = 골라진.mode;
590
- const w = getWork(골라진.mode);
591
- say('');
592
- say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')}`
593
- + ` ${c.gray('말 속에 ' + 골라진.why + ' 가 있어서')}`
594
- + (canWrite(골라진.mode) ? '' : ` ${c.green('· 파일은 안 바꿉니다')}`));
595
- say(` ${c.gray('다르면')} ${c.cyan('/code')} ${c.gray('처럼 직접 고르세요. 그때부터는 안 바뀝니다.')}`);
596
- }
597
- }
598
-
599
- say('');
600
- const started = Date.now();
601
- const before = { in: session.usage.in, out: session.usage.out };
602
-
603
- // 어디까지 적었는지. 도중에 죽어도 여기까지는 남아 있게 자주 흘려 보낸다.
604
- let saved = session.messages.length;
605
- const flush = () => {
606
- for (const m of session.messages.slice(saved)) store.append(m);
607
- saved = session.messages.length;
608
- };
609
- let tools = 0;
610
- let thinkChars = 0;
611
- let streamed = false;
612
- let thinkingShown = false;
613
- let stage = null;
614
- // 접는 표시. 끝나거나 다른 글을 찍기 전에 반드시 멈춰야 한다.
615
- let 접는중 = false;
616
- const 접기멈춤 = () => { if (접는중) { 화면.돌림멈춤(); 접는중 = false; } };
617
-
618
- const clearThinking = () => { 화면.임시지움(); thinkingShown = false; };
619
- // 단계 꼬리표 붙을 때만 뒤에 한 칸을 같이 붙인다. 쉬움 수준에서는 빈 글자라
620
- // '생각 중…' 앞에 빈칸 두 개가 뜨는 일이 없다.
621
- const 꼬리표 = (ev) => { const t = stageTag(ev, session.level); return t ? t + ' ' : ''; };
622
-
623
- turn = new AbortController();
624
- /*
625
- * 상자를 '일하는 중' 으로 바꾼다.
626
- *
627
- * 로컬 모델은 느리다 — 한 걸음에 수십 초가 걸린다. 그 동안 화면 아래가
628
- * 비어 있으면 사람은 멈춘 줄 알고 Ctrl+C 를 누른다. 다 되어 가던 일이
629
- * 그렇게 날아간다. 테두리를 그대로 두고 안엣것만 바꾸는 이유다.
630
- */
631
- 화면.일시작(session, '생각');
632
- try {
633
- for await (const ev of run(session, ctx, 보낼글, { signal: turn.signal })) {
634
- switch (ev.type) {
635
- // 어느 단계를 어떤 강도로 도는지 — 추론 강도 조절이 실제로 먹는지 눈으로 보인다.
636
- case 'stage':
637
- stage = ev;
638
- thinkChars = 0;
639
- break;
640
-
641
- // 다시 부르는 이유. 쉬움 수준에서는 토큰 숫자를 안 꺼낸다 —
642
- // 9984 16384 무슨 뜻인지 설명할 자리가 여기가 아니다.
643
- case 'retry':
644
- clearThinking();
645
- say(session.level === '쉬움'
646
- ? ` ${c.yellow('↻')} ${c.gray('답이 잘려서 더 길게 다시 받습니다')}`
647
- : ` ${c.yellow('↻')} ${c.gray(`${ev.why} — 상한을 ${ev.from} → ${ev.to} 로 올려 다시 부릅니다`)}`);
648
- break;
649
-
650
- case 'waiting':
651
- 화면.기다림(c.gray(꼬리표(stage) + '생각 중…'));
652
- // 지워야 줄이 화면에 있다고 표시해 둔다.
653
- //
654
- // 전에는 표시를 안 세웠다. \r 로 커서만 앞으로 보내 놓고 지우지는
655
- // 않으니, 다음에 오는 짧은 글이 그 줄 위에 겹쳐 찍혔다 —
656
- // "이어가기·low 생각 중… 합계를 sum 으로…" 처럼 앞말이 남았다.
657
- if (process.stdout.isTTY) thinkingShown = true;
658
- break;
659
-
660
- case 'thinking':
661
- thinkChars += ev.text.length;
662
- 화면.생각(`${mark.think} ${c.gray(꼬리표(stage))}${c.gray(`생각 중… ${thinkChars.toLocaleString()}자`)}`);
663
- if (process.stdout.isTTY) thinkingShown = true;
664
- break;
665
-
666
- /*
667
- * 모델이 사람에게 하는 말.
668
- *
669
- * 왼쪽에 세로줄을 세워 도구 줄과 가른다. 전에는 들여쓰기도 색도 도구와
670
- * 같아서, 화면을 훑을 '모델이 뭐라고 했는지' 눈으로 못 찾았다.
671
- * 도구 이름·결과·바뀐 자리가 줄줄이 지나간 끝에 답이 섞여 있었다.
672
- *
673
- * 세로줄 하나면 된다. 칸을 나눠 그리는 화면으로 이유가 없다
674
- * 파이프로 넘기거나 기록으로 남길 때도 그대로 읽힌다.
675
- */
676
- case 'content':
677
- clearThinking();
678
- if (!streamed) { streamed = true; 화면.일바꿈('답'); 화면.붙임(` ${답표시} `); }
679
- 화면.붙임(ev.text.replace(/\n/g, `\n ${답표시} `));
680
- break;
681
-
682
- case 'tool_start':
683
- clearThinking();
684
- if (streamed) { say(''); streamed = false; }
685
- // 문구를 지금 하는 일에 맞춘다. 아무 말이나 돌려 대면 두 번째부터
686
- // 아무도 안 읽고, 그때부터는 화면이 조용한 것과 같아진다.
687
- 화면.일바꿈(갈래고르기(ev.name));
688
- say('');
689
- say(` ${toolLabel(ev.name, ev.args)}`);
690
- break;
691
-
692
- // 여럿을 같이 돌린다 줄로 알리고, 이름은 결과와 붙여서 그린다.
693
- case 'tools_start':
694
- clearThinking();
695
- if (streamed) { say(''); streamed = false; }
696
- 화면.일바꿈(갈래고르기(ev.names?.[0]));
697
- say('');
698
- say(` ${c.gray(`${ev.count}개를 함께 돌립니다`)} ${c.gray('·')} ${c.gray(ev.names.join(' '))}`);
699
- break;
700
-
701
- case 'tool':
702
- tools++;
703
- // 걸러져 나온 것(인자가 잘렸거나, 모르는 도구거나, 거부된 것)은
704
- // '시작' 안 거쳤다. 그래서 '생각 중…' 줄이 안 지워진 채로 결과가
705
- // 뒤에 가서 붙고, 이름도 없이 "└ 인자가 잘렸습니다" 남는다.
706
- // 무슨 도구가 왜 그랬는지 알 수 없는 화면이 된다.
707
- if (ev.showLabel) { clearThinking(); if (streamed) { say(''); streamed = false; } say(''); }
708
- // 같이 것은 이름을 다시 적어 준다. 그러면 어느 결과인지 모른다.
709
- if (ev.parallel || ev.showLabel) say(` ${toolLabel(ev.name, ev.args)}`);
710
- if (ev.name === 'TodoWrite' && ev.result?.todos) {
711
- for (const t of ev.result.todos) {
712
- const = t.state === 'done' ? c.green('☑') : t.state === 'doing' ? c.hyellow('▶') : c.gray('☐');
713
- const 글 = t.state === 'done' ? c.gray(t.text) : t.state === 'doing' ? c.white(t.text) : c.gray(t.text);
714
- say(` ${표} ${clip(글, 74)}`);
715
- }
716
- } else {
717
- say(` ${toolResultLine(ev.result, ev.ms ?? 0)}`);
718
- // 파일을 고쳤으면 무엇이 바뀌었는지 바로 보여 준다.
719
- //
720
- // auto 모드는 안 물어보고 고친다. 여기서 안 보여주면 사람이
721
- // 무엇이 바뀐지 볼 방법이 아예 없다 — 되돌릴지 말지도 못 정한다.
722
- if (ev.result?.diff) {
723
- // 도구가 돌려준 절대경로를 쓴다. 인자로 온 file_path 는 'a.py' 처럼
724
- // 상대경로일 있고, 그러면 나중에 지금 폴더 기준으로 풀려서
725
- // 엉뚱한 자리를 가리킨다 — 목록에 ../../.. 가 찍힌다.
726
- session.noteChange(ev.result.changed ?? ev.args?.file_path, ev.result.diff);
727
- for (const l of renderDiff(ev.result.diff, { maxLines: DIFF_LINES[session.level] ?? 20 })) say(l);
728
- }
729
- }
730
- flush(); // 도구가 하나 끝날 때마다 적어 둔다
731
- break;
732
-
733
- case 'trimmed':
734
- say(` ${c.gray(`(컨텍스트가 차서 오래된 대화 ${ev.dropped}개를 줄였습니다)`)}`);
735
- break;
736
-
737
- /*
738
- * 접는 중.
739
- *
740
- * 여기는 모델을 한 번 더 부르는 자리라 최대 1분이 걸린다. 전에는 글자
741
- * 줄을 \r 로 찍어 놓고 끝이었다 — 움직이는 것이 없으니 답 도중에
742
- * 화면이 멈춘 것처럼 보였다. 실제로 그렇게 보고 강제 종료한 적이 있다.
743
- * 돌아가는 표시를 세워 두면 '기다리면 되는 것' 임을 있다.
744
- * (Ctrl+C 로 멈출 수도 있다 — 그건 compact.js 쪽에서 받는다.)
745
- */
746
- case 'compacting':
747
- 화면.일바꿈('접기');
748
- clearThinking();
749
- 화면.돌리기('컨텍스트가 찼습니다 — 앞선 대화를 요약해 접는 중…');
750
- 접는중 = true;
751
- break;
752
-
753
- case 'compacted': {
754
- 접기멈춤();
755
- const 줄인 = ev.before - ev.after;
756
- say(` ${c.cyan('')} ${c.gray(`대화 ${ev.folded}개를 요약으로 접었습니다 — `)}` +
757
- `${c.gray(ev.before.toLocaleString())} ${c.gray('→')} ${c.white(ev.after.toLocaleString())} ${c.gray('토큰')} ` +
758
- `${c.green(`(${Math.round((줄인 / Math.max(1, ev.before)) * 100)}% 줄어듦)`)}`);
759
- if (ev.fallback) say(` ${c.yellow('요약을 받아 그냥 줄였습니다.')}`);
760
- // 접히면 이력이 통째로 바뀐다. 덧붙이기로는 못 맞추니 새로 적는다.
761
- store.replace(session.messages, `압축 — ${ev.folded}개를 요약으로`);
762
- saved = session.messages.length;
763
- break;
764
- }
765
-
766
- // 서버가 거절하면서 알려 한계를 받아 적었다. 실패로 보이면 된다 —
767
- // 사용자 눈에는 잠깐 멈췄다가 그냥 되는 것으로 보여야 맞다.
768
- case 'learned':
769
- clearThinking();
770
- say(` ${c.cyan('◎')} ${c.gray(
771
- ev.what === 'ctx'
772
- ? `서버가 알려 준 컨텍스트 한계 ${ev.limit.toLocaleString()} 으로 맞추고 다시 부릅니다`
773
- + (ev.asked ? c.gray(` (${ev.asked.toLocaleString()} 을 보냈었습니다)`) : '')
774
- + (ev.guessed ? c.gray(' 숫자를 안 알려 줘서 절반으로 줄여 봅니다') : '')
775
- : `서버가 알려 준 답 길이 한계 ${ev.limit.toLocaleString()} 으로 맞추고 다시 부릅니다`,
776
- )}`);
777
- if (session.level === '개발자' && ev.from) say(` ${c.gray(clip(ev.from, 110))}`);
778
- break;
779
-
780
- case 'compact_failed':
781
- 접기멈춤();
782
- say(` ${c.gray(`(접지 못했습니다: ${ev.why})`)}`);
783
- break;
784
-
785
- case 'limit':
786
- say('');
787
- say(` ${mark.warn} 도구 호출 ${ev.steps}회에서 멈췄습니다. ${c.gray('이어서 하려면 다시 말씀하세요.')}`);
788
- 만든파일보이기(ev.files);
789
- break;
790
-
791
- // 같은 자리를 계속 반복하고 있다. 두면 컨텍스트만 차고 아무것도 안 나온다.
792
- case 'stuck':
793
- clearThinking();
794
- if (streamed) { say(''); streamed = false; }
795
- say('');
796
- say(` ${c.yellow('⊘')} ${c.bold('같은 자리에서 헛돌고 있어 멈췄습니다.')}`);
797
- say(` ${c.gray(ev.why)}`);
798
- if (/잘렸|잘립니다|잘려/.test(String(ev.why))) {
799
- say(` ${c.gray('한 번에 만들 내용이 모델의 출력 한도보다 큽니다.')}`);
800
- // 여기서 /think 권한 적이 있다. 틀린 안내였다 /think '얼마나 생각하나'
801
- // 이지 '얼마나 길게 답하나' 아니다. 오히려 같은 예산에서 생각을 하게
802
- // 만들어 답을 잘리게 한다. 출력 상한은 /out 이다.
803
- say(` ${c.gray('출력 한도를 올리려면')} ${c.cyan('/out')}${c.gray(' 지금 값을 보고 올려 보세요.')}`);
804
- say(` ${c.gray('그래도 되면 나눠서 시키셔도 됩니다 예: "뼈대만 먼저" "표 추가" → "그래프 추가"')}`);
805
- } else {
806
- say(` ${c.gray('같은 방법으로는 됩니다. 다르게 시켜 보시거나, 무엇을 하려는지 한 줄로 알려 주세요.')}`);
807
- }
808
- // 멈췄어도 여기까지 만든 것은 있다. 그것부터 알려 준다
809
- // 없는 알고 다시 시키면 앞서 만든 것을 덮어쓴다.
810
- 만든파일보이기(ev.files);
811
- break;
812
-
813
- case 'aborted':
814
- clearThinking();
815
- if (streamed) { say(''); streamed = false; }
816
- say('');
817
- // 남았는지 아닌지를 **사실대로** 말한다. 전에는 무조건 '남아 있다' 했는데
818
- // 실제로는 아무것도 남는 경우가 있었다. 그러면 "이어서 해줘" 라고 했을
819
- // 모델이 방금 제가 말을 모른다. 안내가 거짓이면 하느니만 못하다.
820
- say(` ${c.yellow('⊘')} ${c.gray(ev.kept
821
- ? '중단했습니다. 여기까지는 대화에 남아 있으니 이어서 말씀하세요.'
822
- : '중단했습니다. 아직 받은 것이 없어 대화에는 아무것도 안 남았습니다 — 다시 말씀하셔야 합니다.')}`);
823
- break;
824
-
825
- case 'error':
826
- clearThinking();
827
- say('');
828
- 오류보이기(ev.text);
829
- break;
830
-
831
- case 'done':
832
- clearThinking();
833
- if (streamed) say('');
834
- 만든파일보이기(ev.files);
835
- break;
836
- }
837
- }
838
- } catch (err) {
839
- 접기멈춤();
840
- clearThinking();
841
- say('');
842
- 오류보이기(err.message);
843
- }
844
- 접기멈춤();
845
- // 어떻게 끝났든 일하는 표시는 반드시 걷는다. 오류로 빠져나온 길에서
846
- // 걷으면 돌아가는 표시가 화면에 붙박이로 남고, 시계도 계속 돈다.
847
- 화면.일끝();
848
- turn = null;
849
- interrupted = false; // 중단은 '끝내기' 의사가 아니다. 종료 카운트를 되돌린다.
850
- flush(); // 오류로 끝났어도 여기까지는 남긴다
851
-
852
- // ── 꼬리말 이번 턴만의 숫자 ────────────────────────────────────────
853
- const secs = ((Date.now() - started) / 1000).toFixed(1);
854
- const bits = [`${secs}초`];
855
- if (tools) bits.push(`도구 ${tools}회`);
856
- const dIn = session.usage.in - before.in;
857
- const dOut = session.usage.out - before.out;
858
- if (dIn || dOut) bits.push(`↑${dIn.toLocaleString()} ↓${dOut.toLocaleString()}`);
859
- say('');
860
- say(` ${c.gray('─'.repeat(2))} ${c.gray(bits.join(c.gray(' · ')))}`);
861
- }
862
-
863
- rl.close();
864
- // 띄운 남의 프로세스는 반드시 거둔다. 안 거두면 deel 을 껐는데도
865
- // 그 서버가 계속 돌고 있게 된다 — 사람 눈에는 안 보이는 채로.
866
- for (const s of mcp붙임.서버들) s.닫기();
867
- // 끝맺음은 화면을 접기 **전에** 그린다. close() 가 상자를 걷어내므로,
868
- // 그 뒤에 찍으면 걷어낸 자리에 뜬금없이 한 줄이 남는다.
869
- say('');
870
- say(` ${c.gray('끝냅니다.')} ${c.gray(`모델 호출 ${session.usage.calls}회 · 도구 시간 ${(session.usage.ms / 1000).toFixed(1)}초 · ↑${session.usage.in.toLocaleString()} ↓${session.usage.out.toLocaleString()}`)}`);
871
- say('');
872
- 화면.close();
873
- return 0;
874
- }
875
-
876
- /**
877
- * "첫 판단·high" 처럼 지금 도는 단계를 짧게.
878
- *
879
- * 쉬움 수준에서는 아무것도 안 붙인다. '이어가기·low' 우리 내부 단계 이름과
880
- * 추론 강도이지 사람이 시킨 것이 아니다 — 고를 일이 없는 사람에게 보여 주면
881
- * 그것부터 무슨 뜻인지 걱정하게 된다. 그냥 '생각 중…' 이면 된다.
882
- */
883
- function stageTag(ev, level) {
884
- if (!ev || level === '쉬움') return '';
885
- const label = STAGES[ev.stage]?.label ?? ev.stage;
886
- return `${label}·${ev.level}`;
887
- }
1
+ // 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
2
+ import { createInterface, emitKeypressEvents } from 'node:readline';
3
+ import { homedir } from 'node:os';
4
+ import { resolve } from 'node:path';
5
+ import { c, say as 바로쓰기, mark, clip } from './ui/ansi.js';
6
+ import { headerLines } from './ui/status.js';
7
+ import { 화면고르기 } from './ui/screen.js';
8
+ import { STAGES } from './agent/effort.js';
9
+ import { handle, COMMANDS } from './commands.js';
10
+ import { next as nextWork, get as getWork, canWrite } from './agent/modes.js';
11
+ import { route } from './agent/route.js';
12
+ import { run } from './agent/loop.js';
13
+ import { Session } from './agent/session.js';
14
+ import { makeScope } from './safety/guard.js';
15
+ import { History } from './safety/undo.js';
16
+ import { Audit } from './safety/audit.js';
17
+ import { activeProfile, load, resolveKey, save as saveCfg } from './config.js';
18
+ import { discover } from './skills/discover.js';
19
+ import { allowEndpoint, setOffline, isOffline, isLocalHost } from './safety/network.js';
20
+ import { Store, latest, prune } from './agent/store.js';
21
+ import { askHidden } from './ui/prompt.js';
22
+ import { explain, shows as levelShows } from './ui/level.js';
23
+ import { 고르기 as 승인고르기, 다음 as 승인다음 } from './ui/approve.js';
24
+ import { 추천, 채울글 } from './ui/complete.js';
25
+ import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
26
+ import { renderDiff, shortStat } from './ui/diff.js';
27
+ import { expand as expandMentions } from './agent/mention.js';
28
+ import { 다붙이기 } from './backend/mcp.js';
29
+ import { 프롬프트토막 as 기억토막, 읽기 as 기억읽기 } from './agent/memory.js';
30
+ import { 갈래고르기 } from './ui/working.js';
31
+ import { 모두끝내기 as 일감모두끝내기, 일감인자 } from './tools/jobs.js';
32
+
33
+ // 도구마다 눈에 띄는 글자를 다르게 준다. 훑을 때 종류가 먼저 보인다.
34
+ const TOOL_GLYPH = {
35
+ Read: c.blue(''),
36
+ Write: c.green(''),
37
+ Edit: c.yellow(''),
38
+ Glob: c.magenta(''),
39
+ Grep: c.magenta(''),
40
+ Bash: c.hcyan(''),
41
+ Skill: c.hmagenta(''),
42
+ WebFetch: c.hblue(''),
43
+ TodoWrite: c.hyellow('☰'),
44
+ Outline: c.hmagenta('❉'), // 뼈대 — 찾기(❋❊)와 한 무리라 비슷한 글자로
45
+ Verify: c.hgreen('✓'), // 확인 결과가 초록·빨강으로 갈리는 유일한 도구
46
+ Task: c.hmagenta('⌥'), // 하위 작업 — 여닫는 줄과 같은 글자를 쓴다
47
+ Jobs: c.hcyan('◐'), // 뒤에서 도는 — Bash(▶)와 한 무리라 같은 색으로
48
+ };
49
+
50
+ // 도구 호출을 줄로 요약 Read(src/a.js) 처럼.
51
+ function toolLabel(name, args) {
52
+ const a = args ?? {};
53
+ const first =
54
+ a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ?? a.목적 ??
55
+ (a.command ? String(a.command).replace(/\s+/g, ' ').slice(0, 52) : null) ??
56
+ // 뒤에서 도는 명령. 번호가 곧 그 일감의 이름이다 — 빈 괄호를 띄우면
57
+ // 어느 것을 보고 있는지가 화면에서 사라진다. 서너 개를 띄워 놓고 나면
58
+ // `Jobs` 줄이 여러 개 겹치는데, 그때 구별할 것이 번호뿐이다.
59
+ // 번호 없이 부르는 (목록 보기) 그대로 괄호가 없다.
60
+ //
61
+ // 이름 고르기는 jobs.js 맡긴다. 모델이 `job` 으로 보낼 수도 있는데,
62
+ // 여기서 `a.번호` 보면 도구는 제대로 도는데 화면만 빈 괄호가 된다.
63
+ (() => { const g = 일감인자(a); return g.번호 != null ? `${g.번호}번${g.끝내기 ? ' · 끝내기' : ''}` : null; })() ??
64
+ // 한 번에 여러 파일을 쓸 때. 빈 괄호를 띄우면 화면만 보고는 무엇을
65
+ // 만들었는지 알 수 없다 — 첫 파일과 개수를 적는다.
66
+ (Array.isArray(a.files) && a.files.length
67
+ ? `${a.files[0]?.file_path ?? '?'}${a.files.length > 1 ? ` 외 ${a.files.length - 1}개` : ''}`
68
+ : null) ??
69
+ // 번에 여러 군데를 고칠 때. 파일 수와 군데 수는 다르다 — 한 파일을
70
+ // 여섯 군데 고치는 것이 보통이라 '외 5개' 라고 적으면 거짓이 된다.
71
+ (Array.isArray(a.edits) && a.edits.length
72
+ ? `${a.edits[0]?.file_path ?? '?'}${a.edits.length > 1 ? ` 외 ${a.edits.length - 1}군데` : ''}`
73
+ : null) ??
74
+ (Array.isArray(a.paths) && a.paths.length ? `${a.paths.length}개` : null) ??
75
+ // 목록은 보여줄 경로가 없다. 괄호를 띄우느니 개수를 적는다.
76
+ (Array.isArray(a.todos) ? `${a.todos.length}건` : null) ?? '';
77
+ const g = TOOL_GLYPH[name] ?? c.cyan('⏺');
78
+ const 안 = clip(String(first ?? ''), 56);
79
+ return `${g} ${c.bold(name)}${안 ? `${c.gray('(')}${c.gray(안)}${c.gray(')')}` : ''}`;
80
+ }
81
+
82
+ function toolResultLine(result, ms) {
83
+ const t = ms > 700 ? c.gray(` ${(ms / 1000).toFixed(1)}초`) : '';
84
+ if (result?.error) return `${c.red('└')} ${c.red(clip(String(result.error).split('\n')[0], 80))}${t}`;
85
+
86
+ /*
87
+ * 확인 결과는 색으로 갈라 준다.
88
+ *
89
+ * `확인 3개 · 확인 2개` 회색 한 줄로 적으면 탈이 났는지가 눈에 안 들어온다.
90
+ * 결과가 초록/빨강으로 갈리는 도구는 이것뿐이라 여기서만 따로 그린다
91
+ * 갈림이 도구를 넣은 이유이기도 하다.
92
+ */
93
+ if (typeof result?.탈 === 'number') {
94
+ const 조각 = [];
95
+ if (result.탈) 조각.push(c.red(`탈 ${result.탈}개`));
96
+ if (result.확인됨) 조각.push(c.green(`확인 ${result.확인됨}개`));
97
+ if (result.못확인) 조각.push(c.yellow(`못 확인 ${result.못확인}개`));
98
+ return `${result.탈 ? c.red('└') : c.gray('└')} `
99
+ + `${조각.join(c.gray(' · ')) || c.gray('확인할 것이 없었습니다')}${t}`;
100
+ }
101
+
102
+ // 고친 자리는 몇 줄이 늘고 줄었는지를 요약 옆에 붙인다.
103
+ const = result?.diff ? ` ${shortStat(result.diff)}` : '';
104
+ return `${c.gray('└')} ${c.gray(clip(result?.summary ?? '완료', 80))}${셈}${t}`;
105
+ }
106
+
107
+ // 수준별로 줄까지 펼칠지. 초보에게 60줄을 쏟으면 아무것도 읽는다.
108
+ const DIFF_LINES = { 쉬움: 14, 개발자: 40 };
109
+
110
+ // 모델의 앞에 세우는 세로줄. 도구 줄과 눈으로 갈리게 하는 유일한 표시다.
111
+ const 답표시 = c.hcyan('▌');
112
+
113
+ export async function chatLoop(opts = {}) {
114
+ const cfg = load();
115
+ const prof = activeProfile(cfg);
116
+ // 연결이 없으면 화면을 세우기 전에 끝난다. 세운 뒤에 나가면 상자를
117
+ // 그렸다 지우는 제어문자가 이 안내 사이에 끼어 화면이 지저분해진다.
118
+ if (!prof) {
119
+ 바로쓰기('');
120
+ 바로쓰기(` ${mark.warn} 저장된 연결이 없습니다. ${c.cyan('deel setup')} 을 먼저 실행하세요.`);
121
+ 바로쓰기('');
122
+ return 1;
123
+ }
124
+
125
+ /*
126
+ * 여기서부터 화면에 나가는 것은 전부 `화면` 을 거친다.
127
+ *
128
+ * `say` 를 지역 이름으로 다시 묶은 이유: 이 함수 안에 출력이 79군데 있었다.
129
+ * 이름을 60번 바꿔 적으면 그중 한둘은 반드시 어긋나고, 어긋난 자리는
130
+ * 화면에서만 티가 난다. 이름은 그대로 두고 **가는 곳만** 바꾼다.
131
+ * 그러면 갈라내기가 옳은지를 지금 있는 검사들이 그대로 준다
132
+ * 글자 하나라도 달라지면 검사가 잡는다.
133
+ *
134
+ * 파일 밖으로 나가는 이름이 아니다. 화면을 세우기 전에 쓰는 자리는
135
+ * 위처럼 `바로쓰기` 를 쓴다.
136
+ */
137
+ const 화면 = await 화면고르기({ tui: opts.tui });
138
+ const say = (s = '') => 화면.줄(s);
139
+
140
+ const root = opts.root ? opts.root : process.cwd();
141
+ const conn = {
142
+ kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
143
+ key: resolveKey(prof), model: prof.model,
144
+ // 컨텍스트 길이. 순서가 우선순위다
145
+ // deel --ctx 655360 > 프로필에 저장된 값 > 기본값
146
+ // 기본값으로 떨어졌다는 것은 '아직 못 쟀다' 는 뜻이다. 아래에서 그렇다고 말해 준다.
147
+ ctx: opts.ctx ?? prof.ctx ?? CTX_DEFAULT,
148
+ // 길이 상한. 컨텍스트와 다른 축이다 — 없으면 effort.js 의 울타리를 쓴다.
149
+ // deel --max-tokens 32k > 프로필에 저장된 값 > 서버에서 알아낸 값 > 기본값
150
+ maxTokens: opts.maxTokens ?? prof.maxTokens ?? null,
151
+ streaming: prof.streaming ?? false,
152
+ tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
153
+ };
154
+
155
+ // 자리 하나만 연다. 다른 어디로도 나가지 못한다.
156
+ allowEndpoint(conn.base);
157
+ if (opts.offline ?? prof.offline) setOffline(true);
158
+
159
+ const session = new Session(conn, {
160
+ root,
161
+ mode: opts.mode ?? 'auto',
162
+ // 처음부터 원하는 모드로 시작할 수 있다 — deel --work plan
163
+ work: opts.work ?? null,
164
+ // 수준은 설정에 남는다. 고르면 다음에 때도 그대로다.
165
+ level: opts.level ?? cfg.level ?? null,
166
+ think: opts.think ?? 'medium',
167
+ effort: opts.effort ?? 'save',
168
+ maxSteps: opts.maxSteps ?? null, // null 이면 작업 모드가 정한다
169
+ });
170
+
171
+ // ── 대화 이어하기 ─────────────────────────────────────────────────────
172
+ // 껐다 켜도 이어지도록, 메시지가 오갈 때마다 .deel/sessions/ 에 바로 적는다.
173
+ let store = null;
174
+ if (opts.sessionId || opts.continue) {
175
+ const target = opts.sessionId ?? latest(root)?.id;
176
+ if (!target) {
177
+ say('');
178
+ say(` ${c.gray('이어할 대화가 없습니다. 새로 시작합니다.')}`);
179
+ } else {
180
+ store = new Store(root, target);
181
+ const { messages } = store.load();
182
+ if (messages.length) {
183
+ session.messages = messages;
184
+ say('');
185
+ say(` ${mark.ok} ${c.bold(target)} ${c.gray(`— 메시지 ${messages.length}개를 이어 받았습니다.`)}`);
186
+ }
187
+ }
188
+ }
189
+ if (!store) store = new Store(root);
190
+ store.begin({ model: conn.model, base: conn.base, root });
191
+ try { prune(root); } catch {}
192
+
193
+ /*
194
+ * 밖에서 붙인 도구(MCP) 서버를 띄운다.
195
+ *
196
+ * 기본은 꺼져 있다 — .deel/mcp.json 에 사람이 직접 적어야만 뜬다. 남의
197
+ * 프로그램을 띄우는 일이라, 이 프로젝트가 존재하는 이유('미승인 SW 반입 금지')
198
+ * 정면으로 부딪히기 때문이다. 자물쇠(--offline)가 걸려 있으면 아예 안 띄운다.
199
+ */
200
+ const mcp붙임 = await 다붙이기(root, {
201
+ offline: isOffline(),
202
+ audit: new Audit(root),
203
+ });
204
+
205
+ // 이 PC 에 있는 스킬·명령·플러그인을 찾아 붙인다. 품고 다니지 않는다.
206
+ const found = discover(root);
207
+ session.skills = found.skills;
208
+ session.commands = found.commands;
209
+ session.plugins = found.plugins;
210
+ // 세션도 알아야 한다 밖에서 붙인 도구도 스키마가 매 요청에 실린다.
211
+ // 세면 컨텍스트가 그만큼 조용히 줄어든다.
212
+ session.mcp = mcp붙임.서버들;
213
+ // 지난 대화에서 정해 것을 들고 시작한다.
214
+ // 기억은 Session 이 켤 때 직접 읽는다 (session.js 생성자) deel run 같은 것을 들고 시작하게.
215
+
216
+ /*
217
+ * 입력 상자를 쓸 때는 readline 이 스스로 되비추지 못하게 한다.
218
+ *
219
+ * readline 자기가 아는 커서 자리를 기준으로 지우고 다시 그린다. 그런데
220
+ * 자리는 우리가 그린 상자 테두리 안이 아니라 줄 맨 앞이다. 그대로 두면
221
+ * 백스페이스가 테두리를 갉아먹고, 긴 글이 접힐 때 상자가 무너진다.
222
+ *
223
+ * 그래서 되비추는 일만 뺏는다. 어디까지나 **되비추기만** 이다 — 한글 조합,
224
+ * 붙여넣기, 위아래 이력, Ctrl+A/E, 백스페이스는 전부 readline 이 그대로
225
+ * 맡는다. 우리는 readline 이 들고 있는 글(rl.line) 상자 안에 그릴 뿐이다.
226
+ * 편집을 직접 짜기 시작하면 한글 입력기부터 깨진다.
227
+ */
228
+ const 상자쓰나 = 화면.kind === 'box';
229
+ // 사람이 지금 입력을 기다리는 중인가. 도구가 도는 동안 키를 눌러도
230
+ // 상자를 다시 그리면 안 된다 — 그 자리는 이미 대화가 흘러가고 있다.
231
+ let 입력기다림 = false;
232
+ // 되묻는 중이면 앞머리. 상자 대신 줄로 되비춘다.
233
+ let 묻는중 = null;
234
+ // 이번 틱에 다시 그리기로 이미 잡아 뒀나 (붙여넣기로 키가 쏟아질 때)
235
+ let 그릴예정 = false;
236
+ const 먹통 = { write() { return true; }, end() {}, on() {}, once() {}, emit() {}, removeListener() {} };
237
+ const rl = createInterface({
238
+ input: process.stdin,
239
+ output: 상자쓰나 ? 먹통 : process.stdout,
240
+ terminal: 상자쓰나 ? true : undefined,
241
+ historySize: 200,
242
+ /*
243
+ * 완성기. 아무것도 내놓지만 **달아 둬야** 한다.
244
+ *
245
+ * 네 가지를 실제로 눌러 보고 정했다.
246
+ *
247
+ * 완성기 없음 Tab 줄에 리터럴 탭이 박힌다 (`/hel` + Tab → `/hel\t`).
248
+ * 그 글이 그대로 모델에게 간다. 검사 6개가 여기서 빨개진다.
249
+ * 진짜 완성기 readline 자기 방식으로 줄을 고쳐 버린다. 우리가 그린
250
+ * 상자와 어긋나고, 후보가 있는 줄에서 Shift+Tab 누르면
251
+ * 승인 방식만 바뀌어야 하는데 글까지 바뀐다.
252
+ * 완성기 Tab Shift+Tab 줄을 건드린다. 이것
253
+ *
254
+ * 그래서 readline 에게서는 '줄을 안 건드림' 만 받고, 무엇을 채울지는 우리가
255
+ * rl.write 직접 정한다. rl.write 공개 API 한글도 깨진다.
256
+ */
257
+ completer: 상자쓰나 ? (line) => [[], line] : undefined,
258
+ });
259
+
260
+ // 입력을 큐로 받는다. rl.question 을 겹쳐 쓰면 파이프로 넣을 때 닫혀 버린다.
261
+ const queue = [];
262
+ let waiter = null;
263
+ let closed = false;
264
+ const echo = !process.stdin.isTTY; // 파이프·기록용일 때는 입력을 되비춘다
265
+
266
+ rl.on('line', (l) => {
267
+ if (echo) say(c.gray(l));
268
+ if (상자쓰나) {
269
+ if (묻는중 !== null) {
270
+ // 되묻는 자리: 답을 그 줄에 남긴 채 줄만 넘긴다.
271
+ process.stdout.write(`\r\x1b[2K${묻는중}${c.white(l)}\n`);
272
+ } else if (입력기다림) {
273
+ // 상자를 걷어내고, 사람이 보낸 글을 대화에 남긴다. 남기면 스크롤을
274
+ // 올렸을 답만 있고 무엇을 물었는지가 없다.
275
+ 화면.입력지움();
276
+ if (l.trim()) say(` ${c.hcyan('❯')} ${c.white(l)}`);
277
+ } else if (l.trim()) {
278
+ /*
279
+ * 일하는 도중에 미리 쳐 둔 것.
280
+ *
281
+ * 여기서 대화에 `❯ …` 를 찍으면 **이미 보낸 것처럼** 보인다. 실제로는
282
+ * 지금 일이 끝난 뒤에 나가므로, 그때 가서 찍는다(아래 for 문). 지금은
283
+ * 상자에 건이 밀려 있는지만 세어 준다.
284
+ */
285
+ 화면.대기갱신('', queue.length + 1);
286
+ }
287
+ }
288
+ if (waiter) { const w = waiter; waiter = null; w(l); }
289
+ else queue.push(l);
290
+ });
291
+ rl.on('close', () => {
292
+ closed = true;
293
+ if (waiter) { const w = waiter; waiter = null; w(null); }
294
+ });
295
+
296
+ /*
297
+ * 지금 치고 있는 글에 맞는 명령들.
298
+ *
299
+ * 수준에 따라 감춘 명령이 있다(쉬움에서는 자주 쓰는 것만 보인다). 그런데
300
+ * **감춘 것이 못 쓰는 것은 아니다** — 치면 그대로 돌아간다. 그래서 보이는
301
+ * 것 중에 맞는 게 없으면 감춘 것까지 뒤진다. `/recall` 을 아는 사람이
302
+ * 쉬움 수준이라는 이유로 "그런 명령 없다" 는 화면을 보면 안 된다.
303
+ */
304
+ const 지금추천 = (글) => {
305
+ const 보이는것 = Object.keys(COMMANDS).filter((n) => n !== 'quit' && levelShows(session.level, n));
306
+ const = 추천(글, COMMANDS, 보이는것);
307
+ if (것.length) return 것;
308
+ return 추천(글, COMMANDS, Object.keys(COMMANDS).filter((n) => n !== 'quit'));
309
+ };
310
+
311
+ /*
312
+ * 키를 가로챈다 — 터미널일 때만.
313
+ *
314
+ * 파이프로 넣을 가로채면 입력이 깨진다. 검사와 데모가 그렇게 돌아간다.
315
+ *
316
+ * ── Shift+Tab 은 무엇을 돌려야 하나 ──────────────────────────────────
317
+ *
318
+ * 전에는 **작업 모드**(종합/코드/계획…)를 돌렸다. 바꾼다. Shift+Tab 은
319
+ * **승인 방식**을 돌린다 — 안 묻고 고칠지, 매번 물을지.
320
+ *
321
+ * 가지가 자리를 놓고 다퉜는데, 자주 눌러야 하는 쪽이 이겨야 한다.
322
+ * 작업 모드는 요청을 보고 저절로 옮겨 가므로 사람이 손댈 일이 드물다.
323
+ * 반면 승인 방식은 "이번 좀 봐야겠다" 싶을 때 **일하는 도중에** 바꾸고
324
+ * 싶어진다. 그리고 이건 안전 설정이라, 손이 기억하는 자리에 있어야 한다.
325
+ * 다른 도구(Claude Code) 같은 키에 같은 것을 둔다.
326
+ *
327
+ * 작업 모드는 Ctrl+O 로 옮겼다. `/work` 도 그대로 된다.
328
+ */
329
+ if (process.stdin.isTTY) {
330
+ emitKeypressEvents(process.stdin, rl);
331
+ process.stdin.on('keypress', (_ch, key) => {
332
+ // Shift+Tab — 승인 방식 (자동 → 위험만 → 모두)
333
+ if (key && key.name === 'tab' && key.shift) {
334
+ const 앞 = 승인고르기(session.mode);
335
+ session.mode = 승인다음(session.mode);
336
+ const 뒤 = 승인고르기(session.mode);
337
+ 화면.입력지움();
338
+ say(` ${뒤.색(뒤.글자)} ${c.bold(뒤.색(뒤.이름))} ${c.gray(뒤.한줄)}`);
339
+ say(` ${c.gray(`${앞.이름} ${뒤.이름} · Shift+Tab 으로 계속 바꿉니다`)}`);
340
+ prompt();
341
+ return;
342
+ }
343
+ // Ctrl+O — 작업 모드 (종합/코드/계획/설계/디버그/묻기/총괄)
344
+ if (key && key.ctrl && key.name === 'o') {
345
+ session.work = nextWork(session.work);
346
+ const w = getWork(session.work);
347
+ 화면.입력지움();
348
+ say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')} ${c.gray(w.hint)}`
349
+ + (canWrite(session.work) ? '' : ` ${c.green('· 파일을 못 바꿉니다')}`));
350
+ prompt();
351
+ return;
352
+ }
353
+ /*
354
+ * Tab 치던 슬래시 명령을 채운다.
355
+ *
356
+ * 하나만 맞으면 끝까지, 여럿이면 다 같이 가진 앞부분까지. 목록에서
357
+ * 위아래로 고르게 하지 않는다 — 그러면 지난 입력 이력(위 화살표)을
358
+ * 뺏어야 하는데, 그건 훨씬 자주 쓰는 기능이다.
359
+ */
360
+ if (key && key.name === 'tab' && !key.shift && 상자쓰나 && 입력기다림 && 묻는중 === null) {
361
+ const 채울 = 채울글(rl.line ?? '', 지금추천(rl.line ?? ''));
362
+ if (채울) rl.write(채울);
363
+ // 채울 없어도 그리기는 한다 후보 목록이 그대로 남아 있어야 한다.
364
+ 화면.입력갱신(session, rl.line ?? '', rl.cursor ?? 0, 지금추천(rl.line ?? ''));
365
+ return;
366
+ }
367
+ /*
368
+ * 친 것을 상자 안에 그린다.
369
+ *
370
+ * readline 키를 처리하고 rl.line 고친 **뒤에** 그려야 하는데,
371
+ * keypress 전에 온다. 그래서 한 틱 미룬다. 안 미루면 늘 한 글자
372
+ * 뒤처진 글이 보인다 치는 사람 눈에는 마지막 글자가 안 찍히는 것으로
373
+ * 보이고, 그게 제일 못 미더운 화면이다.
374
+ */
375
+ if (!상자쓰나) return;
376
+ if (key && key.name === 'return') return; // 줄이 끝나는 것은 'line' 맡는다
377
+ if (묻는중 !== null) {
378
+ const = 묻는중;
379
+ setImmediate(() => {
380
+ if (묻는중 === null) return;
381
+ process.stdout.write(`\r\x1b[2K${앞}${c.white(rl.line ?? '')}`);
382
+ });
383
+ return;
384
+ }
385
+ /*
386
+ * 여러 키가 한꺼번에 들어와도 **한 번만** 그린다.
387
+ *
388
+ * 붙여넣기는 글자 수만큼 키가 쏟아진다. 스무 줄짜리를 붙이면 상자를
389
+ * 수백 번 다시 그리게 되고, 화면이 눈에 띄게 떨린다. 어차피 마지막
390
+ * 한 번이 지금 상태이므로, 이번 틱에 이미 잡아 뒀으면 그냥 넘긴다.
391
+ */
392
+ if (그릴예정) return;
393
+ 그릴예정 = true;
394
+ setImmediate(() => {
395
+ 그릴예정 = false;
396
+ if (입력기다림) {
397
+ 화면.입력갱신(session, rl.line ?? '', rl.cursor ?? 0, 지금추천(rl.line ?? ''));
398
+ } else {
399
+ /*
400
+ * 일하는 도중에 치고 있는 글.
401
+ *
402
+ * readline 이미 이걸 받아 두고 있었다 — 화면에 안 보였을 뿐이다.
403
+ * 그래서 사람은 "작업 중에는 못 친다" 고 생각하고 끝나기를 지켜본다.
404
+ * 분짜리 턴에서 시간이 통째로 버려진다. 새로 받는 게 아니라
405
+ * **이미 받고 있던 것을 보여 주기만** 한다.
406
+ *
407
+ * 답이 흘러나오는 동안( 중간)에는 그려도 줄을 덮으므로,
408
+ * 상자 쪽에서 알아서 넘긴다. 글은 그대로 살아 있다.
409
+ */
410
+ 화면.대기갱신(rl.line ?? '', queue.length);
411
+ }
412
+ });
413
+ });
414
+ }
415
+
416
+ const nextLine = () => {
417
+ if (queue.length) return Promise.resolve(queue.shift());
418
+ if (closed) return Promise.resolve(null);
419
+ return new Promise((res) => { waiter = res; });
420
+ };
421
+
422
+ const ask = async (label, o = {}) => {
423
+ const 앞 = ` ${c.gray('›')} ${label} ${o.def ? c.gray(`[${o.def}] `) : ''}`;
424
+ 화면.붙임();
425
+ /*
426
+ * 되묻는 자리는 상자를 쓴다 '실행할까요? (y/n)' 에 테두리를 두르면
427
+ * 대화의 흐름이 끊긴다. 대신 되비추는 일은 우리가 맡아야 한다.
428
+ * 상자 모드에서는 readline 의 되비추기를 꺼 놨기 때문이다. 안 해 주면
429
+ * y 쳐도 화면에 아무것도 안 나타난다 — 먹은 건지 안 먹은 건지 모른다.
430
+ */
431
+ 묻는중 = 상자쓰나 ? : null;
432
+ try {
433
+ const a = await nextLine();
434
+ if (a === null) return o.def ?? '';
435
+ return a.trim() || o.def || '';
436
+ } finally { 묻는중 = null; }
437
+ };
438
+
439
+ /**
440
+ * 오류를 이 사람 수준에 맞게 보여준다.
441
+ *
442
+ * 쉬움 수준에서는 무엇을 하면 되는지를 앞에 놓고, 원래 문구는 회색으로 뒤에 남긴다.
443
+ * 원인을 지우지 않는 것이 중요하다 지우면 물어볼 수도 없게 된다.
444
+ * 개발자 수준에서는 원래 문구 그대로다.
445
+ */
446
+ const 오류보이기 = (message) => {
447
+ const r = explain(session.level, message);
448
+ if (!r.plain) { say(` ${c.red('✗')} ${String(message)}`); return; }
449
+ const [머리, ...나머지] = r.text.split('\n');
450
+ say(` ${c.red('✗')} ${머리}`);
451
+ for (const l of 나머지) say(` ${l}`);
452
+ if (r.detail) say(` ${c.gray(`(원래 문구: ${clip(String(r.detail).split('\n')[0], 90)})`)}`);
453
+ };
454
+
455
+ /**
456
+ * 이번 턴에 만들어진 파일을 **디스크를 보고** 말해 준다.
457
+ *
458
+ * 모델이 "만들었습니다" 라고 답을 맺어도 그건 모델의 말이다. 도구가 실패했는데
459
+ * 그렇게 맺는 경우가 실제로 있다. 사용자는 그 말을 믿고 다음 일로 넘어가고,
460
+ * 한참 뒤에야 파일이 없다는 안다. 그때는 대화가 이미 흘러가 있다.
461
+ *
462
+ * 그래서 말 대신 파일을 본다. 몇 줄인지 · 몇 KB 인지까지 적는다 —
463
+ * '만들어지긴 했는데 반쪽' 경우를 숫자로 바로 알아볼 있다.
464
+ */
465
+ const 만든파일보이기 = (files) => {
466
+ if (!Array.isArray(files) || !files.length) return;
467
+ say('');
468
+ for (const f of files) {
469
+ const 이름 = ctx?.scope ? ctx.scope.show(f.path) : f.path;
470
+ if (f.missing) {
471
+ say(` ${c.yellow('⚠')} ${c.white(이름)} ${c.gray('— 만들어지지 않았습니다')}`);
472
+ continue;
473
+ }
474
+ if (f.dir) continue;
475
+ const kb = f.bytes >= 1024 ? `${(f.bytes / 1024).toFixed(1)}KB` : `${f.bytes}B`;
476
+ say(` ${c.green('✓')} ${c.white(이름)} ${c.gray(`· ${f.lines.toLocaleString()}줄 · ${kb}`)}`);
477
+ }
478
+ };
479
+
480
+ const ctx = {
481
+ scope: makeScope(root),
482
+ // 도구가 번에 돌려줄 양을 값에서 뽑는다 (agent/budget.js).
483
+ // /model 갈아타면 conn 통째로 바뀌므로 그때마다 다시 읽는다.
484
+ get 모델컨텍스트() { return conn.ctx ?? null; },
485
+ history: new History(root),
486
+ audit: new Audit(root),
487
+ seen: new Set(),
488
+ // 붙은 MCP 서버. 도구를 부를 때 여기서 찾는다.
489
+ mcp: mcp붙임.서버들,
490
+ skills: found.skills,
491
+ loadedSkills: new Set(),
492
+ ask,
493
+ // 암호는 여기서만 받는다. 받은 값은 도구가 쓰고 버린다
494
+ // 설정에도, 세션 기록에도, 감사기록에도, 명령줄에도 남는다.
495
+ askPassword: async (label) => {
496
+ if (closed) return null;
497
+ const pw = await askHidden(rl, label, nextLine);
498
+ return pw === null || pw === '' ? null : pw;
499
+ },
500
+ confirm: async (name, args) => {
501
+ say('');
502
+ say(` ${c.yellow('?')} ${toolLabel(name, args)}`);
503
+ const a = (await ask('실행할까요? (y/n)', { def: 'y' })).toLowerCase();
504
+ return a === 'y' || a === 'yes' || a === 'ㅇ';
505
+ },
506
+ };
507
+
508
+ // ── 컨텍스트 길이를 모델에서 긁어온다 ─────────────────────────────────
509
+ //
510
+ // 때마다 서버에 물어본다. 저장된 값을 그대로 믿지 않는다 —
511
+ // 같은 이름의 모델이라도 서버에서 k 로 올렸는지가 그때그때 다르고,
512
+ // 차이를 화면에 보면 조용히 작아진 채로 쓰게 된다.
513
+ //
514
+ // --ctx 로 직접 주신 값이 있으면 안 건드린다. 사람이 고른 것을 뒤집지 않는다.
515
+ const 길이알림 = []; // 소식
516
+ const 길이경고 = []; // 손을 봐야 하는
517
+ if (opts.ctx == null) {
518
+ // 컴퓨터 안의 서버면 깜짝할 새다. 사내 게이트웨이는 초 걸릴 수 있어
519
+ // 무슨 일이 일어나는 중인지 알려 준다 멈춘 것처럼 보이면 된다.
520
+ 화면.돌리기('모델에 걸린 컨텍스트 길이를 확인하는 중…');
521
+ let r = null;
522
+ try { r = await probeCtx(conn, { timeout: 6000 }); } catch { /* 못 물어보면 아래에서 처리 */ }
523
+ 화면.돌림멈춤('');
524
+ if (r?.value) {
525
+ const = conn.ctx;
526
+ conn.ctx = r.value;
527
+ // 알아낸 값은 프로필에 남긴다. 다음에 켤 때 화면이 곧바로 맞게 뜬다.
528
+ if (prof.ctx !== r.value) {
529
+ prof.ctx = r.value;
530
+ try { const cfg2 = load(); const t = cfg2.profiles.find((p) => p.id === prof.id); if (t) { t.ctx = r.value; saveCfg(cfg2); } } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
531
+ }
532
+ if (전 !== r.value) 길이알림.push(`컨텍스트를 ${전.toLocaleString()} ${c.gray('→')} ${c.white(r.value.toLocaleString())} 로 맞췄습니다 ${c.gray('(' + (r.source ?? '서버') + '에서 읽음)')}`);
533
+ if (r.max && r.loaded && r.max > r.loaded) {
534
+ 길이경고.push(`이 모델은 ${c.white(r.max.toLocaleString())} 까지 됩니다 — 서버에서 더 올린 뒤 ${c.cyan('/ctx auto')}`);
535
+ }
536
+ /*
537
+ * '모델이 있는 최대''서버가 실제로 올려 둔 길이' 는 다르다.
538
+ *
539
+ * 모델 카드에는 131,072 라고 적혀 있는데 서버는 8,192 만 올려 둔 경우가
540
+ * 흔하다. 그런데 우리는 둘 중 아는 것을 그냥 썼다. 그러면 16배를 보내고
541
+ * **조용히 잘린다** — 오류도 안 나고, 모델이 앞부분을 잊을 뿐이라
542
+ * 사람은 "모델이 멍청해졌다" 고만 느낀다. 확신에 찬 오답이 제일 나쁘다.
543
+ *
544
+ * 이제는 거절당하면 그 문장에서 배워 스스로 맞춘다(backend/learn.js).
545
+ * 그래도 처음부터 그렇다고 말해 두는 편이 낫다.
546
+ */
547
+ if (!r.loaded && r.max) {
548
+ 길이경고.push(`${c.white(r.max.toLocaleString())} ${c.bold('모델이 낼 수 있는 최대')}입니다 — 서버가 실제로 올린 길이는 안 알려 줍니다.`
549
+ + `\n 너무 길면 서버가 알려 주는 값으로 저절로 맞춥니다. 아는 값이 있으면 ${c.cyan('/ctx 8192')} 처럼 직접 정하세요`);
550
+ }
551
+ } else if (prof.ctx == null) {
552
+ 길이경고.push(`컨텍스트를 서버가 안 알려줍니다 — 우선 ${CTX_DEFAULT.toLocaleString()} 으로 잡았습니다. ${c.cyan('/ctx 655360')} 처럼 직접 지정하세요`);
553
+ }
554
+ }
555
+
556
+ // ── 머리말 ────────────────────────────────────────────────────────────
557
+ 화면.머리말(headerLines(session, found));
558
+ const warn = [];
559
+ // 폴더에서 경우. 작업 범위가 전체가 된다.
560
+ //
561
+ // 실제로 이렇게 화면을 봤다. 그러면 Glob 이 홈 전체를 훑어 느려지고,
562
+ // 모델이 ~/.deel, ~/.claude, ~/package.json 같은 상관없는 것부터 읽는다.
563
+ // 막혀 있어서 안전하긴 하지만, 애초에 여기서 일이 아니다.
564
+ // 윈도우는 같은 폴더라도 대소문자가 다르게 있다. 맞춰서 견준다.
565
+ const 같은폴더 = (a, b) => {
566
+ const n = (p) => resolve(String(p ?? '')).replace(/[\\/]+$/, '');
567
+ return process.platform === 'win32'
568
+ ? n(a).toLowerCase() === n(b).toLowerCase()
569
+ : n(a) === n(b);
570
+ };
571
+ if (같은폴더(root, homedir())) {
572
+ warn.push('홈 폴더에서 켰습니다 — 작업 범위가 집 전체입니다. 일할 폴더로 옮겨 다시 켜는 편이 빠르고 안전합니다');
573
+ }
574
+ // 밖에서 붙인 도구는 **붙었다고 반드시 말한다.** 남의 프로그램이 이 컴퓨터에서
575
+ // 돌고 있다는 사실을 조용히 넘기면 안 된다 — 그게 이 도구가 심사를 통과한 근거다.
576
+ // 기억을 들고 시작한다는 것을 반드시 말한다. 조용히 실으면 사람은 왜 모델이
577
+ // 알려준 것을 아는지 모른다 그게 불안하다.
578
+ {
579
+ const 기억 = 기억읽기(root);
580
+ if (기억.줄들.length) 길이알림.push(`지난 대화에서 정한 것 ${기억.줄들.length}개를 들고 시작합니다 — ${c.cyan('/memory')}`);
581
+ }
582
+ if (mcp붙임.서버들.length) {
583
+ const 도구수 = mcp붙임.서버들.reduce((n, s) => n + s.도구.length, 0);
584
+ 길이알림.push(`밖에서 붙인 도구 ${도구수}개 ${c.gray(`(서버 ${mcp붙임.서버들.length}대: ${mcp붙임.서버들.map((s) => s.이름).join(' · ')})`)} ${c.cyan('/mcp')}`);
585
+ }
586
+ // 것은 조용히 빠지면 안 된다. "왜 그 도구가 없지" 를 영영 알 수 없다.
587
+ for (const m of mcp붙임.못한것) warn.push(`MCP ${c.white(m.이름)} 을 못 붙였습니다 — ${m.왜}`);
588
+ if (!conn.tools) warn.push('도구 호출이 확인되지 않았습니다 — deel diagnose 로 점검하세요');
589
+ if (!conn.streaming) warn.push('스트리밍이 없어 응답이 한 번에 나옵니다');
590
+ warn.push(...길이경고);
591
+ // 잘 된 것은 경고 표시를 달지 않는다. ⚠ 가 붙으면 뭘 고쳐야 하나 싶어진다.
592
+ for (const l of 길이알림) say(` ${mark.ok} ${c.gray(l)}`);
593
+ for (const w of warn) say(` ${mark.warn} ${c.gray(w)}`);
594
+ say(` ${c.gray('/help 명령 목록')} ${c.gray('/think 추론 강도')} ${c.gray('Ctrl+C 중단·끝내기')}`);
595
+
596
+ /*
597
+ * 입력 자리. 어떻게 생겼는지는 화면 쪽이 정한다 —
598
+ * 줄화면은 상태줄을 깔고 그 아래 ❯ 를, 상자화면은 테두리를 두른 칸을 그린다.
599
+ *
600
+ * 치던 글은 되살린다. Shift+Tab 이나 Ctrl+C 처럼 **입력 도중에** 한 줄을
601
+ * 끼워 넣고 다시 그리는 자리가 있는데, 그때 빈 칸을 그리면 치던 글이
602
+ * 사라진 것처럼 보인다. 실제로는 readline 이 그대로 들고 있어서 Enter 를
603
+ * 치면 멀쩡히 보내진다 화면만 거짓말을 하는 셈이라 나쁘다.
604
+ */
605
+ const prompt = () => {
606
+ const = 상자쓰나 && 입력기다림 ? (rl.line ?? '') : '';
607
+ 화면.입력자리(session, 글, 글 ? (rl.cursor ?? 0) : 0, 글 ? 지금추천(글) : []);
608
+ };
609
+
610
+ // Ctrl+C 상황에 따라 뜻이 다르다.
611
+ // 모델이 답하는 중 → 그 답을 끊는다 (프로그램은 살아 있다)
612
+ // 입력을 기다리는 → 한 번은 경고, 두 번이면 끝낸다
613
+ // 느린 로컬 모델이 엉뚱한 답을 길게 뽑기 시작했을 때 끝까지 기다리지 않아도 된다.
614
+ let interrupted = false;
615
+ let turn = null; // 지금 도는 턴의 AbortController
616
+ rl.on('SIGINT', () => {
617
+ if (turn && !turn.signal.aborted) {
618
+ turn.abort();
619
+ return; // 화면 정리는 루프 'aborted' 이벤트가 한다
620
+ }
621
+ if (interrupted) { rl.close(); return; }
622
+ interrupted = true;
623
+ say('');
624
+ say(` ${c.gray('한 번 더 Ctrl+C 를 누르면 끝냅니다.')}`);
625
+ prompt();
626
+ });
627
+
628
+ for (;;) {
629
+ prompt();
630
+ 입력기다림 = true;
631
+ // 줄이 이미 쌓여 있으면 그건 **일하는 동안 미리 쳐 둔 것**이다.
632
+ // 그때는 대화에 안 찍었으니(찍으면 이미 보낸 것처럼 보인다) 지금 찍는다.
633
+ const 예약이었나 = queue.length > 0;
634
+ const line = await nextLine();
635
+ 입력기다림 = false;
636
+ if (line === null) break; // 입력이 끝났다 (파이프 종료 / Ctrl+D)
637
+ interrupted = false;
638
+ const text = line.trim();
639
+ if (!text) continue;
640
+ if (예약이었나 && 상자쓰나) {
641
+ 화면.입력지움();
642
+ say(` ${c.hcyan('❯')} ${c.white(text)} ${c.gray('(미리 것)')}`);
643
+ }
644
+
645
+ const cmd = await handle(text, session, ctx);
646
+ if (cmd.exit) break;
647
+ if (cmd.handled) continue;
648
+ const toSend = cmd.text ?? text; // 슬래시 명령이면 펼쳐진 내용을 보낸다
649
+
650
+ // @파일 을 지목했으면 그 파일을 바로 붙여 보낸다.
651
+ //
652
+ // 붙인 것은 화면에 반드시 알린다. 사람이 안 보낸 줄 아는 글이 대화에
653
+ // 들어가 있으면 안 된다 — 컨텍스트가 왜 줄었는지도 모르게 된다.
654
+ let 보낼글 = toSend;
655
+ if (toSend.includes('@')) {
656
+ const 예산 = Math.min(20000, Math.floor((session.conn.ctx ?? CTX_DEFAULT) * 0.25));
657
+ const r = expandMentions(toSend, {
658
+ scope: ctx.scope, budget: 예산, seen: ctx.seen,
659
+ onRead: (p, t) => session.noteRead(p, t),
660
+ });
661
+ 보낼글 = r.text;
662
+ for (const a of r.attached) {
663
+ say(` ${c.blue('◧')} ${c.gray('붙임')} ${c.white(a.show)}${a.full ? '' : c.gray(' (앞부분만)')}`);
664
+ }
665
+ for (const b of r.blocked) {
666
+ say(` ${mark.warn} ${c.gray(`${b.path} 는 작업 범위 밖이라 안 붙였습니다.`)}`);
667
+ }
668
+ }
669
+
670
+ // 종합 모드면 한마디가 무슨 일인지 보고 알맞은 모드로 옮긴다.
671
+ //
672
+ // 기본 모드는 안 건드린다 — 다음 한마디는 다시 처음부터 고른다.
673
+ // 사용자가 직접 고른 모드가 있으면 여기 들어온다. 사람이 고른 것을 뒤집지 않는다.
674
+ session.routed = null;
675
+ if (session.work === 'auto') {
676
+ const 골라진 = route(toSend);
677
+ if (골라진.mode) {
678
+ session.routed = 골라진.mode;
679
+ const w = getWork(골라진.mode);
680
+ say('');
681
+ say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')}`
682
+ + ` ${c.gray('말 속에 ' + 골라진.why + ' 가 있어서')}`
683
+ + (canWrite(골라진.mode) ? '' : ` ${c.green('· 파일은 안 바꿉니다')}`));
684
+ say(` ${c.gray('다르면')} ${c.cyan('/code')} ${c.gray('처럼 직접 고르세요. 그때부터는 안 바뀝니다.')}`);
685
+ }
686
+ }
687
+
688
+ say('');
689
+ const started = Date.now();
690
+ const before = { in: session.usage.in, out: session.usage.out };
691
+
692
+ // 어디까지 적었는지. 도중에 죽어도 여기까지는 남아 있게 자주 흘려 보낸다.
693
+ let saved = session.messages.length;
694
+ const flush = () => {
695
+ for (const m of session.messages.slice(saved)) store.append(m);
696
+ saved = session.messages.length;
697
+ };
698
+ let tools = 0;
699
+ let thinkChars = 0;
700
+ let streamed = false;
701
+ let thinkingShown = false;
702
+ let stage = null;
703
+ // 접는 표시. 끝나거나 다른 글을 찍기 전에 반드시 멈춰야 한다.
704
+ let 접는중 = false;
705
+ const 접기멈춤 = () => { if (접는중) { 화면.돌림멈춤(); 접는중 = false; } };
706
+
707
+ const clearThinking = () => { 화면.임시지움(); thinkingShown = false; };
708
+ // 단계 꼬리표 붙을 때만 뒤에 칸을 같이 붙인다. 쉬움 수준에서는 빈 글자라
709
+ // '생각 중…' 앞에 빈칸 두 개가 뜨는 일이 없다.
710
+ const 꼬리표 = (ev) => { const t = stageTag(ev, session.level); return t ? t + ' ' : ''; };
711
+
712
+ turn = new AbortController();
713
+ /*
714
+ * 상자를 '일하는 중' 으로 바꾼다.
715
+ *
716
+ * 로컬 모델은 느리다 — 한 걸음에 수십 초가 걸린다. 그 동안 화면 아래가
717
+ * 비어 있으면 사람은 멈춘 줄 알고 Ctrl+C 를 누른다. 다 되어 가던 일이
718
+ * 그렇게 날아간다. 테두리를 그대로 두고 안엣것만 바꾸는 이유다.
719
+ */
720
+ 화면.일시작(session, '생각');
721
+ try {
722
+ for await (const ev of run(session, ctx, 보낼글, { signal: turn.signal })) {
723
+ /*
724
+ * 하위 작업 안쪽에서 것이면 들여 그린다.
725
+ *
726
+ * 여기 한 줄이 아래 switch 의 say() 예순 곳을 다 덮는다. 각 자리마다
727
+ * 들여쓰기를 붙이면 이벤트를 더할 때마다 하나씩 빠뜨리게 된다.
728
+ */
729
+ 화면.들여쓰기(ev.depth ?? 0);
730
+ switch (ev.type) {
731
+ // 어느 단계를 어떤 강도로 도는지 — 추론 강도 조절이 실제로 먹는지 눈으로 보인다.
732
+ case 'stage':
733
+ stage = ev;
734
+ thinkChars = 0;
735
+ break;
736
+
737
+ // 다시 부르는 이유. 쉬움 수준에서는 토큰 숫자를 안 꺼낸다 —
738
+ // 9984 → 16384 가 무슨 뜻인지 설명할 자리가 여기가 아니다.
739
+ case 'retry':
740
+ clearThinking();
741
+ say(session.level === '쉬움'
742
+ ? ` ${c.yellow('↻')} ${c.gray('답이 잘려서 길게 다시 받습니다')}`
743
+ : ` ${c.yellow('↻')} ${c.gray(`${ev.why} 상한을 ${ev.from} ${ev.to} 올려 다시 부릅니다`)}`);
744
+ break;
745
+
746
+ case 'waiting':
747
+ 화면.기다림(c.gray(꼬리표(stage) + '생각 중…'));
748
+ // 지워야 할 줄이 화면에 있다고 표시해 둔다.
749
+ //
750
+ // 전에는 이 표시를 안 세웠다. \r 로 커서만 앞으로 보내 놓고 지우지는
751
+ // 않으니, 다음에 오는 짧은 글이 그 줄 위에 겹쳐 찍혔다 —
752
+ // "이어가기·low 생각 중… 합계를 sum 으로…" 처럼 앞말이 남았다.
753
+ if (process.stdout.isTTY) thinkingShown = true;
754
+ break;
755
+
756
+ case 'thinking':
757
+ thinkChars += ev.text.length;
758
+ 화면.생각(`${mark.think} ${c.gray(꼬리표(stage))}${c.gray(`생각 중… ${thinkChars.toLocaleString()}자`)}`);
759
+ if (process.stdout.isTTY) thinkingShown = true;
760
+ break;
761
+
762
+ /*
763
+ * 모델이 사람에게 하는 말.
764
+ *
765
+ * 왼쪽에 세로줄을 세워 도구 줄과 가른다. 전에는 들여쓰기도 색도 도구와
766
+ * 같아서, 화면을 훑을 '모델이 뭐라고 했는지' 눈으로 찾았다.
767
+ * 도구 이름·결과·바뀐 자리가 줄줄이 지나간 끝에 답이 섞여 있었다.
768
+ *
769
+ * 세로줄 하나면 된다. 칸을 나눠 그리는 화면으로 갈 이유가 없다 —
770
+ * 파이프로 넘기거나 기록으로 남길 때도 그대로 읽힌다.
771
+ */
772
+ case 'content':
773
+ clearThinking();
774
+ if (!streamed) { streamed = true; 화면.일바꿈('답'); 화면.붙임(` ${답표시} `); }
775
+ 화면.붙임(ev.text.replace(/\n/g, `\n ${답표시} `));
776
+ break;
777
+
778
+ case 'tool_start':
779
+ clearThinking();
780
+ if (streamed) { say(''); streamed = false; }
781
+ // 문구를 지금 하는 일에 맞춘다. 아무 말이나 돌려 대면 두 번째부터
782
+ // 아무도 안 읽고, 그때부터는 화면이 조용한 것과 같아진다.
783
+ 화면.일바꿈(갈래고르기(ev.name));
784
+ say('');
785
+ say(` ${toolLabel(ev.name, ev.args)}`);
786
+ break;
787
+
788
+ // 여럿을 같이 돌린다 — 한 줄로 알리고, 이름은 결과와 붙여서 그린다.
789
+ case 'tools_start':
790
+ clearThinking();
791
+ if (streamed) { say(''); streamed = false; }
792
+ 화면.일바꿈(갈래고르기(ev.names?.[0]));
793
+ say('');
794
+ say(` ${c.gray(`${ev.count}개를 함께 돌립니다`)} ${c.gray('·')} ${c.gray(ev.names.join(' '))}`);
795
+ break;
796
+
797
+ case 'tool':
798
+ tools++;
799
+ // 걸러져 나온 것(인자가 잘렸거나, 모르는 도구거나, 거부된 것)
800
+ // '시작' 거쳤다. 그래서 '생각 중…' 줄이 지워진 채로 결과가
801
+ // 뒤에 가서 붙고, 이름도 없이 "└ 인자가 잘렸습니다" 남는다.
802
+ // 무슨 도구가 그랬는지 없는 화면이 된다.
803
+ if (ev.showLabel) { clearThinking(); if (streamed) { say(''); streamed = false; } say(''); }
804
+ // 같이 것은 이름을 다시 적어 준다. 그러면 어느 결과인지 모른다.
805
+ if (ev.parallel || ev.showLabel) say(` ${toolLabel(ev.name, ev.args)}`);
806
+ if (ev.name === 'TodoWrite' && ev.result?.todos) {
807
+ for (const t of ev.result.todos) {
808
+ const = t.state === 'done' ? c.green('☑') : t.state === 'doing' ? c.hyellow('▶') : c.gray('☐');
809
+ const = t.state === 'done' ? c.gray(t.text) : t.state === 'doing' ? c.white(t.text) : c.gray(t.text);
810
+ say(` ${표} ${clip(글, 74)}`);
811
+ }
812
+ } else {
813
+ say(` ${toolResultLine(ev.result, ev.ms ?? 0)}`);
814
+ /*
815
+ * 파일을 바꾸는 명령이었으면, 무엇을 뒀는지 적는다.
816
+ *
817
+ * `mv`·`rm` 화면에 '성공' 줄만 남는다. 줄만 보면 되돌릴
818
+ * 있는지 없는지 길이 없어서, 사람은 되돌릴 있는 알고
819
+ * 넘어가거나 반대로 되돌리는 알고 겁을 낸다. 사실을 적는다.
820
+ */
821
+ if (ev.result?.되돌릴것?.length) {
822
+ const 것들 = ev.result.되돌릴것;
823
+ say(` ${c.gray(`↩ ${것들.slice(0, 3).join(' · ')}${것들.length > 3 ? ` 외 ${것들.length - 3}개` : ''} 는 떠 뒀습니다 — /undo 로 되돌아갑니다`)}`);
824
+ }
825
+ // 파일을 고쳤으면 무엇이 바뀌었는지 바로 보여 준다.
826
+ //
827
+ // auto 모드는 안 물어보고 고친다. 여기서 안 보여주면 사람이
828
+ // 무엇이 바뀐지 볼 방법이 아예 없다 — 되돌릴지 말지도 못 정한다.
829
+ if (ev.result?.diff) {
830
+ // 도구가 돌려준 절대경로를 쓴다. 인자로 온 file_path 는 'a.py' 처럼
831
+ // 상대경로일 수 있고, 그러면 나중에 지금 폴더 기준으로 풀려서
832
+ // 엉뚱한 자리를 가리킨다 — 목록에 ../../.. 가 찍힌다.
833
+ session.noteChange(ev.result.changed ?? ev.args?.file_path, ev.result.diff);
834
+ for (const l of renderDiff(ev.result.diff, { maxLines: DIFF_LINES[session.level] ?? 20 })) say(l);
835
+ } else if (ev.result?.여럿?.length) {
836
+ /*
837
+ * 한 번에 여러 개를 만들거나 고쳤다.
838
+ *
839
+ * Write 는 바뀐 자리를 안 그린다. 새 파일 다섯 개의 diff 는
840
+ * 곧 그 파일 전체라, 화면이 수백 줄로 밀려 올라간다 —
841
+ * 그러면 무엇이 만들어졌는지가 오히려 안 보인다.
842
+ *
843
+ * Edit 은 그린다. 고친 자리는 몇 줄뿐이고, 그 몇 줄이야말로
844
+ * 사람이 봐야 하는 것이다 — auto 모드는 안 물어보고 고치니까.
845
+ * 다만 전체 몫을 정해 두고 안에서만 그린다. 스무 군데를
846
+ * 고치면 화면이 밀려 올라가고, 그러면 그리느니만 못하다.
847
+ */
848
+ let 남은diff = DIFF_LINES[session.level] ?? 20;
849
+ for (const f of ev.result.여럿) {
850
+ if (f.ok) {
851
+ session.noteChange(f.path, f.diff);
852
+ // Write 수, Edit 군데 수. 없는 쪽을 '· undefined줄' 로 적으면 안 된다.
853
+ const = f.lines != null ? `· ${f.lines}줄` : f.군데 != null ? `· ${f.군데}군데` : '';
854
+ say(` ${c.green('✓')} ${c.white(f.보인이름)} ${c.gray(몫)}`);
855
+ if (f.군데 != null && f.diff && 남은diff > 0) {
856
+ const 줄들 = renderDiff(f.diff, { maxLines: 남은diff });
857
+ for (const l of 줄들) say(l);
858
+ 남은diff -= 줄들.length;
859
+ }
860
+ } else {
861
+ say(` ${c.red('✗')} ${c.white(f.보인이름 ?? '(경로 없음)')} ${c.gray(`— ${clip(String(f.error), 60)}`)}`);
862
+ }
863
+ }
864
+ }
865
+ }
866
+ flush(); // 도구가 하나 끝날 때마다 적어 둔다
867
+ break;
868
+
869
+ /*
870
+ * 하위 작업을 떼어 냈다.
871
+ *
872
+ * 이 줄이 없으면 하위가 부른 도구들이 부모 것과 뒤섞여 찍힌다 —
873
+ * 사람은 부모가 파일 열두 개를 읽은 줄로 본다. 여기와 아래 task_done
874
+ * 이 그 구간의 여닫는 괄호다.
875
+ */
876
+ case 'task_start':
877
+ clearThinking();
878
+ if (streamed) { say(''); streamed = false; }
879
+ 화면.일바꿈('하위', clip(ev.목적, 24));
880
+ say('');
881
+ say(` ${c.hmagenta('⌥')} ${c.bold('하위 작업')} ${c.white(clip(ev.목적, 60))}`
882
+ + ` ${c.gray(`· ${getWork(ev.모드).name} · 최대 ${ev.steps}걸음`)}`);
883
+ say(` ${c.gray('여기서부터는 따로 떨어진 대화입니다 — 결과 요약만 위로 올라옵니다.')}`);
884
+ break;
885
+
886
+ case 'task_done': {
887
+ clearThinking();
888
+ if (streamed) { say(''); streamed = false; }
889
+ const 끝 = ev.끝 ?? {};
890
+ const 잘됨 = 끝.type === 'done';
891
+ const 왜 = { done: '끝냈습니다', limit: '걸음 수를 다 써서 멈췄습니다 — 다 못 했습니다',
892
+ stuck: '헛돌아서 스스로 멈췄습니다 — 다 못 했습니다',
893
+ aborted: '중단했습니다' }[끝.type] ?? '끝난 이유를 알 수 없습니다';
894
+ say('');
895
+ say(` ${잘됨 ? c.green('✓') : c.yellow('⚠')} ${c.gray('하위 작업')} ${c.white(clip(ev.목적, 50))}`
896
+ + ` ${c.gray(`— ${왜}`)} ${c.gray(`(${끝.steps ?? 0}걸음)`)}`);
897
+ // 무엇이 실제로 생겼는지는 하위가 한 말이 아니라 디스크가 말한다.
898
+ 만든파일보이기(끝.files);
899
+ if (끝.why) say(` ${c.gray(`막힌 데: ${clip(끝.why, 80)}`)}`);
900
+ break;
901
+ }
902
+
903
+ case 'trimmed':
904
+ say(` ${c.gray(`(컨텍스트가 차서 오래된 대화 ${ev.dropped}개를 줄였습니다)`)}`);
905
+ break;
906
+
907
+ /*
908
+ * 접는 중.
909
+ *
910
+ * 여기는 모델을 한 번 더 부르는 자리라 최대 1분이 걸린다. 전에는 글자
911
+ * 한 줄을 \r 로 찍어 놓고 끝이었다 — 움직이는 것이 없으니 답 도중에
912
+ * 화면이 멈춘 것처럼 보였다. 실제로 그렇게 보고 강제 종료한 적이 있다.
913
+ * 돌아가는 표시를 세워 두면 '기다리면 되는 것' 임을 알 수 있다.
914
+ * (Ctrl+C 로 멈출 수도 있다 — 그건 compact.js 쪽에서 받는다.)
915
+ */
916
+ case 'compacting':
917
+ 화면.일바꿈('접기');
918
+ clearThinking();
919
+ 화면.돌리기('컨텍스트가 찼습니다 — 앞선 대화를 요약해 접는 중…');
920
+ 접는중 = true;
921
+ break;
922
+
923
+ case 'compacted': {
924
+ 접기멈춤();
925
+ const 줄인 = ev.before - ev.after;
926
+ say(` ${c.cyan('◱')} ${c.gray(`대화 ${ev.folded}개를 요약으로 접었습니다 — `)}` +
927
+ `${c.gray(ev.before.toLocaleString())} ${c.gray('→')} ${c.white(ev.after.toLocaleString())} ${c.gray('토큰')} ` +
928
+ `${c.green(`(${Math.round((줄인 / Math.max(1, ev.before)) * 100)}% 줄어듦)`)}`);
929
+ if (ev.fallback) say(` ${c.yellow('요약을 못 받아 그냥 줄였습니다.')}`);
930
+ // 접히면 이력이 통째로 바뀐다. 덧붙이기로는 못 맞추니 새로 적는다.
931
+ store.replace(session.messages, `압축 — ${ev.folded}개를 요약으로`);
932
+ saved = session.messages.length;
933
+ break;
934
+ }
935
+
936
+ // 서버가 거절하면서 알려 준 한계를 받아 적었다. 실패로 보이면 안 된다 —
937
+ // 사용자 눈에는 잠깐 멈췄다가 그냥 잘 되는 것으로 보여야 맞다.
938
+ case 'learned':
939
+ clearThinking();
940
+ say(` ${c.cyan('◎')} ${c.gray(
941
+ ev.what === 'ctx'
942
+ ? `서버가 알려 준 컨텍스트 한계 ${ev.limit.toLocaleString()} 으로 맞추고 다시 부릅니다`
943
+ + (ev.asked ? c.gray(` (${ev.asked.toLocaleString()} 을 보냈었습니다)`) : '')
944
+ + (ev.guessed ? c.gray(' — 숫자를 안 알려 줘서 절반으로 줄여 봅니다') : '')
945
+ : `서버가 알려 준 답 길이 한계 ${ev.limit.toLocaleString()} 으로 맞추고 다시 부릅니다`,
946
+ )}`);
947
+ if (session.level === '개발자' && ev.from) say(` ${c.gray(clip(ev.from, 110))}`);
948
+ break;
949
+
950
+ case 'compact_failed':
951
+ 접기멈춤();
952
+ say(` ${c.gray(`(접지 못했습니다: ${ev.why})`)}`);
953
+ break;
954
+
955
+ case 'limit':
956
+ say('');
957
+ say(` ${mark.warn} 도구 호출 ${ev.steps}회에서 멈췄습니다. ${c.gray('이어서 하려면 다시 말씀하세요.')}`);
958
+ // 무엇이 안 끝났는지 그 자리에 적는다. 위로 스크롤해 도구 줄을
959
+ // 세어 보게 하면, 이어서 시킬 때 무엇을 시켜야 할지 알 수 없다.
960
+ if (ev.남은할일?.length) {
961
+ say('');
962
+ say(` ${c.gray('안 끝난 것')}`);
963
+ for (const t of ev.남은할일.slice(0, 8)) {
964
+ say(` ${t.state === 'doing' ? c.hyellow('▶') : c.gray('☐')} ${c.white(clip(t.text, 70))}`);
965
+ }
966
+ if (ev.남은할일.length > 8) say(` ${c.gray(`… 그 밖에 ${ev.남은할일.length - 8}개`)}`);
967
+ }
968
+ 만든파일보이기(ev.files);
969
+ break;
970
+
971
+ // 같은 자리를 계속 반복하고 있다. 두면 컨텍스트만 차고 아무것도 안 나온다.
972
+ case 'stuck':
973
+ clearThinking();
974
+ if (streamed) { say(''); streamed = false; }
975
+ say('');
976
+ say(` ${c.yellow('⊘')} ${c.bold('같은 자리에서 헛돌고 있어 멈췄습니다.')}`);
977
+ say(` ${c.gray(ev.why)}`);
978
+ if (/잘렸|잘립니다|잘려/.test(String(ev.why))) {
979
+ say(` ${c.gray('한 번에 만들 내용이 모델의 출력 한도보다 큽니다.')}`);
980
+ // 여기서 /think 를 권한 적이 있다. 틀린 안내였다 — /think 는 '얼마나 생각하나'
981
+ // 이지 '얼마나 길게 답하나' 가 아니다. 오히려 같은 예산에서 생각을 더 하게
982
+ // 만들어 답을 더 잘리게 한다. 출력 상한은 /out 이다.
983
+ say(` ${c.gray('출력 한도를 올리려면')} ${c.cyan('/out')}${c.gray(' 로 지금 값을 보고 올려 보세요.')}`);
984
+ say(` ${c.gray('그래도 안 되면 나눠서 시키셔도 됩니다 — 예: "뼈대만 먼저" → "표 추가" → "그래프 추가"')}`);
985
+ } else {
986
+ say(` ${c.gray('같은 방법으로는 안 됩니다. 다르게 시켜 보시거나, 무엇을 하려는지 한 줄로 알려 주세요.')}`);
987
+ }
988
+ // 멈췄어도 여기까지 만든 것은 있다. 그것부터 알려 준다 —
989
+ // 없는 줄 알고 다시 시키면 앞서 만든 것을 덮어쓴다.
990
+ 만든파일보이기(ev.files);
991
+ break;
992
+
993
+ case 'aborted':
994
+ clearThinking();
995
+ if (streamed) { say(''); streamed = false; }
996
+ say('');
997
+ // 남았는지 아닌지를 **사실대로** 말한다. 전에는 무조건 '남아 있다' 고 했는데
998
+ // 실제로는 아무것도 안 남는 경우가 있었다. 그러면 "이어서 해줘" 라고 했을 때
999
+ // 모델이 방금 제가 한 말을 모른다. 안내가 거짓이면 안 하느니만 못하다.
1000
+ say(` ${c.yellow('⊘')} ${c.gray(ev.kept
1001
+ ? '중단했습니다. 여기까지는 대화에 남아 있으니 이어서 말씀하세요.'
1002
+ : '중단했습니다. 아직 받은 것이 없어 대화에는 아무것도 안 남았습니다 — 다시 말씀하셔야 합니다.')}`);
1003
+ break;
1004
+
1005
+ case 'error':
1006
+ clearThinking();
1007
+ say('');
1008
+ 오류보이기(ev.text);
1009
+ break;
1010
+
1011
+ case 'done':
1012
+ clearThinking();
1013
+ if (streamed) say('');
1014
+ 만든파일보이기(ev.files);
1015
+ break;
1016
+ }
1017
+ }
1018
+ } catch (err) {
1019
+ 접기멈춤();
1020
+ clearThinking();
1021
+ say('');
1022
+ 오류보이기(err.message);
1023
+ }
1024
+ 접기멈춤();
1025
+ // 어떻게 끝났든 일하는 표시는 반드시 걷는다. 오류로 빠져나온 길에서
1026
+ // 안 걷으면 돌아가는 표시가 화면에 붙박이로 남고, 시계도 계속 돈다.
1027
+ 화면.일끝();
1028
+ turn = null;
1029
+ interrupted = false; // 중단은 '끝내기' 의사가 아니다. 종료 카운트를 되돌린다.
1030
+ flush(); // 오류로 끝났어도 여기까지는 남긴다
1031
+
1032
+ // ── 꼬리말 — 이번 턴만의 숫자 ────────────────────────────────────────
1033
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
1034
+ const bits = [`${secs}초`];
1035
+ if (tools) bits.push(`도구 ${tools}회`);
1036
+ const dIn = session.usage.in - before.in;
1037
+ const dOut = session.usage.out - before.out;
1038
+ if (dIn || dOut) bits.push(`↑${dIn.toLocaleString()} ↓${dOut.toLocaleString()}`);
1039
+ say('');
1040
+ say(` ${c.gray('─'.repeat(2))} ${c.gray(bits.join(c.gray(' · ')))}`);
1041
+ }
1042
+
1043
+ rl.close();
1044
+ // 띄운 남의 프로세스는 반드시 거둔다. 안 거두면 deel 을 껐는데도
1045
+ // 그 서버가 계속 돌고 있게 된다 — 사람 눈에는 안 보이는 채로.
1046
+ for (const s of mcp붙임.서버들) s.닫기();
1047
+ // 뒤에서 돌던 명령도 같이 거둔다. 이걸 조용히 하면 안 된다 —
1048
+ // 사람은 dev 서버가 아직 떠 있다고 여기고 브라우저를 새로 고치다가
1049
+ // "왜 안 되지" 로 시간을 쓴다. 몇 개를 껐는지 말해 준다.
1050
+ {
1051
+ const 껐다 = 일감모두끝내기();
1052
+ if (껐다) say(` ${mark.ok} ${c.gray(`뒤에서 돌던 명령 ${껐다}개를 같이 껐습니다.`)}`);
1053
+ }
1054
+ // 끝맺음은 화면을 접기 **전에** 그린다. close() 가 상자를 걷어내므로,
1055
+ // 그 뒤에 찍으면 걷어낸 자리에 뜬금없이 한 줄이 남는다.
1056
+ say('');
1057
+ say(` ${c.gray('끝냅니다.')} ${c.gray(`모델 호출 ${session.usage.calls}회 · 도구 시간 ${(session.usage.ms / 1000).toFixed(1)}초 · ↑${session.usage.in.toLocaleString()} ↓${session.usage.out.toLocaleString()}`)}`);
1058
+ say('');
1059
+ 화면.close();
1060
+ return 0;
1061
+ }
1062
+
1063
+ /**
1064
+ * "첫 판단·high" 처럼 지금 도는 단계를 짧게.
1065
+ *
1066
+ * 쉬움 수준에서는 아무것도 안 붙인다. '이어가기·low' 는 우리 내부 단계 이름과
1067
+ * 추론 강도이지 사람이 시킨 것이 아니다 — 고를 일이 없는 사람에게 보여 주면
1068
+ * 그것부터 무슨 뜻인지 걱정하게 된다. 그냥 '생각 중…' 이면 된다.
1069
+ */
1070
+ function stageTag(ev, level) {
1071
+ if (!ev || level === '쉬움') return '';
1072
+ const label = STAGES[ev.stage]?.label ?? ev.stage;
1073
+ return `${label}·${ev.level}`;
1074
+ }