deel-local-cli 1.1.1 → 1.4.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 (60) hide show
  1. package/README.en.md +826 -19
  2. package/README.md +689 -14
  3. package/bin/deel.js +73 -4
  4. package/package.json +7 -3
  5. package/src/acp/jsonrpc.js +230 -0
  6. package/src/acp/map.js +219 -0
  7. package/src/acp/serve.js +556 -0
  8. package/src/agent/budget.js +0 -14
  9. package/src/agent/card.js +110 -0
  10. package/src/agent/compact.js +95 -0
  11. package/src/agent/evidence.js +186 -0
  12. package/src/agent/evolve.js +213 -0
  13. package/src/agent/grade.js +20 -0
  14. package/src/agent/loop.js +172 -14
  15. package/src/agent/models.js +169 -0
  16. package/src/agent/modes.js +49 -1
  17. package/src/agent/pins.js +140 -0
  18. package/src/agent/project.js +10 -4
  19. package/src/agent/session.js +359 -11
  20. package/src/agent/store.js +31 -0
  21. package/src/agent/threads.js +154 -0
  22. package/src/commands.js +751 -55
  23. package/src/config.js +10 -1
  24. package/src/i18n/en.js +244 -0
  25. package/src/i18n/index.js +126 -0
  26. package/src/i18n/ko.js +231 -0
  27. package/src/lsp/client.js +459 -0
  28. package/src/lsp/diag.js +112 -0
  29. package/src/lsp/rpc.js +84 -0
  30. package/src/lsp/servers.js +218 -0
  31. package/src/oneshot.js +23 -0
  32. package/src/pack/sbom.js +218 -0
  33. package/src/pack/selfpack.js +19 -3
  34. package/src/repl.js +273 -22
  35. package/src/safety/guard.js +37 -2
  36. package/src/safety/secrets.js +205 -0
  37. package/src/safety/undo.js +11 -4
  38. package/src/setup.js +2 -2
  39. package/src/skills/discover.js +1 -1
  40. package/src/tools/desc.en.js +219 -0
  41. package/src/tools/edit-match.js +16 -5
  42. package/src/tools/excel-com.js +1 -1
  43. package/src/tools/excel.js +1 -1
  44. package/src/tools/fsutil.js +0 -8
  45. package/src/tools/index.js +149 -6
  46. package/src/tools/lsp.js +327 -0
  47. package/src/tools/task.js +30 -2
  48. package/src/tools/todo.js +19 -3
  49. package/src/ui/ansi.js +42 -2
  50. package/src/ui/approve.js +25 -21
  51. package/src/ui/inputbox.js +69 -13
  52. package/src/ui/intro.js +174 -0
  53. package/src/ui/level.js +11 -5
  54. package/src/ui/md.js +227 -0
  55. package/src/ui/notify.js +101 -0
  56. package/src/ui/prompt.js +1 -1
  57. package/src/ui/screen.js +1 -1
  58. package/src/ui/status.js +190 -34
  59. package/src/ui/working.js +0 -3
  60. package/src/ui/wrap.js +1 -8
package/src/repl.js CHANGED
@@ -1,23 +1,33 @@
1
1
  // 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
2
2
  import { createInterface, emitKeypressEvents } from 'node:readline';
3
3
  import { homedir } from 'node:os';
4
- import { resolve } from 'node:path';
4
+ import { resolve, basename } from 'node:path';
5
5
  import { c, say as 바로쓰기, mark, clip } from './ui/ansi.js';
6
6
  import { headerLines } from './ui/status.js';
7
+ import { 종, 창제목, 제목되돌리기, 알릴까, 제목글 } from './ui/notify.js';
8
+ import { 보이기 as 인트로, 기본곁말 } from './ui/intro.js';
9
+ import { 언어잡기 } from './i18n/index.js';
7
10
  import { 화면고르기 } from './ui/screen.js';
8
11
  import { STAGES } from './agent/effort.js';
9
12
  import { handle, COMMANDS, 미리보기끄기 } from './commands.js';
10
- import { next as nextWork, get as getWork, canWrite } from './agent/modes.js';
13
+ import { next as nextWork, get as getWork, canWrite, 보일이름, 보일한줄 } from './agent/modes.js';
11
14
  import { route } from './agent/route.js';
12
15
  import { run } from './agent/loop.js';
13
- import { Session } from './agent/session.js';
16
+ import { Session, repairToolPairs } from './agent/session.js';
14
17
  import { makeScope } from './safety/guard.js';
18
+ import { 언어서버있나 } from './tools/index.js';
19
+ import { 모두끄기 as 언어서버다끄기 } from './lsp/client.js';
15
20
  import { History } from './safety/undo.js';
16
21
  import { Audit } from './safety/audit.js';
17
- import { activeProfile, load, resolveKey, save as saveCfg } from './config.js';
22
+ import { activeProfile, load, resolveKey, save as saveCfg, homeDir } from './config.js';
18
23
  import { discover } from './skills/discover.js';
19
24
  import { allowEndpoint, setOffline, isOffline, isLocalHost } from './safety/network.js';
20
25
  import { Store, latest, prune } from './agent/store.js';
26
+ import { Threads } from './agent/threads.js';
27
+ import { 못박기 } from './agent/pins.js';
28
+ import { 카드 } from './agent/card.js';
29
+ import { 배움 } from './agent/evolve.js';
30
+ import { 마크다운 } from './ui/md.js';
21
31
  import { askHidden } from './ui/prompt.js';
22
32
  import { explain, shows as levelShows } from './ui/level.js';
23
33
  import { 고르기 as 승인고르기, 다음 as 승인다음 } from './ui/approve.js';
@@ -27,7 +37,7 @@ import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
27
37
  import { renderDiff, shortStat } from './ui/diff.js';
28
38
  import { expand as expandMentions } from './agent/mention.js';
29
39
  import { 다붙이기 } from './backend/mcp.js';
30
- import { 프롬프트토막 as 기억토막, 읽기 as 기억읽기 } from './agent/memory.js';
40
+ import { 읽기 as 기억읽기 } from './agent/memory.js';
31
41
  import { 갈래고르기 } from './ui/working.js';
32
42
  import { 모두끝내기 as 일감모두끝내기, 일감인자 } from './tools/jobs.js';
33
43
 
@@ -157,6 +167,27 @@ export async function chatLoop(opts = {}) {
157
167
  allowEndpoint(conn.base);
158
168
  if (opts.offline ?? prof.offline) setOffline(true);
159
169
 
170
+ /*
171
+ * ── 끝났을 때 알리기 ─────────────────────────────────────────────────
172
+ *
173
+ * 로컬 모델은 느리다. 7B 를 CPU 로 돌리면 한 턴에 2~3분이 예사고, 그동안
174
+ * 사람은 다른 창으로 간다. 돌아와 보면 5분 전에 끝나 있거나, 더 나쁘게는
175
+ * "실행할까요?" 에서 3분째 멈춰 서 있다 — 물어본 줄을 몰라서.
176
+ *
177
+ * 폴더 이름을 같이 들고 있는다. 창을 여럿 띄워 놓고 쓰는 사람이 많은데
178
+ * 전부 'deel' 이면 어느 탭이 끝난 건지 알 수가 없다. (ui/notify.js)
179
+ */
180
+ const 알림 = { 켬: cfg.bell !== false, 폴더: basename(root) };
181
+
182
+ /*
183
+ * 화면 말을 여기서 한 번 정한다.
184
+ *
185
+ * 켤 때 딱 한 번이면 된다 — 그 뒤로는 /lang 이 바꾼다. 환경변수가 설정을
186
+ * 이긴다. 한 번만 영어로 켜 보려는 사람이 설정을 건드리지 않고
187
+ * `DEEL_LANG=en deel` 로 할 수 있어야 하기 때문이다. (i18n/index.js)
188
+ */
189
+ 언어잡기({ cfg });
190
+
160
191
  const session = new Session(conn, {
161
192
  root,
162
193
  mode: opts.mode ?? 'auto',
@@ -179,11 +210,30 @@ export async function chatLoop(opts = {}) {
179
210
  say(` ${c.gray('이어할 대화가 없습니다. 새로 시작합니다.')}`);
180
211
  } else {
181
212
  store = new Store(root, target);
182
- const { messages } = store.load();
213
+ const { messages: 적힌것 } = store.load();
214
+ /*
215
+ * 도구가 도는 중에 죽었으면 호출만 적히고 결과가 없다. 그대로 보내면
216
+ * 규격 서버가 400 을 내서 이어받자마자 첫 마디에서 죽는다. 손봐서 받는다.
217
+ */
218
+ const { messages, 고친것 } = repairToolPairs(적힌것);
183
219
  if (messages.length) {
184
220
  session.messages = messages;
185
221
  say('');
186
222
  say(` ${mark.ok} ${c.bold(target)} ${c.gray(`— 메시지 ${messages.length}개를 이어 받았습니다.`)}`);
223
+ if (고친것) {
224
+ say(` ${c.gray(`중단된 도구 호출 ${고친것}개를 걷어냈습니다 — 그때 하던 일은 다시 시켜 주세요.`)}`);
225
+ }
226
+ }
227
+ /*
228
+ * 못 박아 둔 것도 같이 되살린다 (agent/pins.js).
229
+ *
230
+ * 이걸 안 되살리면 '접어도 요약해도 안 지워진다' 가 **껐다 켜는 한 번에**
231
+ * 거짓이 된다. 사람은 이미 말했다고 믿고 있으니 다시 말하지 않는다.
232
+ */
233
+ const 박힌것 = store.못박은것읽기();
234
+ if (박힌것.length) {
235
+ session.못박은것 = new 못박기(박힌것);
236
+ say(` ${c.gray(`못 박아 둔 것 ${session.못박은것.개수()}개도 그대로 이어 받았습니다 —`)} ${c.cyan('/pin')}`);
187
237
  }
188
238
  }
189
239
  }
@@ -211,6 +261,14 @@ export async function chatLoop(opts = {}) {
211
261
  // 세션도 알아야 한다 — 밖에서 붙인 도구도 스키마가 매 요청에 실린다.
212
262
  // 안 세면 컨텍스트가 그만큼 조용히 줄어든다.
213
263
  session.mcp = mcp붙임.서버들;
264
+ /*
265
+ * 이 자리에 언어 서버가 깔려 있나 (Def·Refs 를 목록에 넣을지).
266
+ *
267
+ * 여기서 한 번만 잰다. PATH 훑기와 폴더 훑기라 값이 있고, 세션 도중에
268
+ * 답이 바뀌지 않는다. 아무것도 안 깔아 준다 — 있으면 쓰고 없으면 없는 대로
269
+ * Grep·Outline 으로 간다 (lsp/servers.js 머리말).
270
+ */
271
+ session.lsp = 언어서버있나(root);
214
272
  // 지난 대화에서 정해 둔 것을 들고 시작한다.
215
273
  // 기억은 Session 이 켤 때 직접 읽는다 (session.js 생성자) — deel run 도 같은 것을 들고 시작하게.
216
274
 
@@ -346,7 +404,7 @@ export async function chatLoop(opts = {}) {
346
404
  session.work = nextWork(session.work);
347
405
  const w = getWork(session.work);
348
406
  화면.입력지움();
349
- say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')} ${c.gray(w.hint)}`
407
+ say(` ${c.hcyan(w.glyph)} ${c.bold(보일이름(w.id))} ${c.gray('(' + w.en + ')')} ${c.gray(보일한줄(w.id))}`
350
408
  + (canWrite(session.work) ? '' : ` ${c.green('· 파일을 못 바꿉니다')}`));
351
409
  prompt();
352
410
  return;
@@ -490,6 +548,8 @@ export async function chatLoop(opts = {}) {
490
548
  mcp: mcp붙임.서버들,
491
549
  skills: found.skills,
492
550
  loadedSkills: new Set(),
551
+ // 고친 뒤 진단을 볼지. /lsp off 로 끈다 (lsp/diag.js).
552
+ lsp: { 켬: true },
493
553
  ask,
494
554
  // 암호는 여기서만 받는다. 받은 값은 도구가 쓰고 버린다 —
495
555
  // 설정에도, 세션 기록에도, 감사기록에도, 명령줄에도 안 남는다.
@@ -501,11 +561,55 @@ export async function chatLoop(opts = {}) {
501
561
  confirm: async (name, args) => {
502
562
  say('');
503
563
  say(` ${c.yellow('?')} ${toolLabel(name, args)}`);
564
+ /*
565
+ * 여기서 종을 울린다. 끝난 것은 늦게 알아도 되지만 **막혀 있는 것은**
566
+ * 기다린 만큼 그대로 손해다 — 다른 창에 가 있는 사이 3분째 이 줄에서
567
+ * 멈춰 서 있는 일이 실제로 잦다. 시간 문턱도 안 본다, 0초여도 알린다.
568
+ */
569
+ if (알릴까({ 물어봄: true, 켬: 알림.켬 })) 종();
570
+ 창제목(제목글('물어봄', { 폴더: 알림.폴더 }));
504
571
  const a = (await ask('실행할까요? (y/n)', { def: 'y' })).toLowerCase();
572
+ 창제목(제목글('도는중', { 폴더: 알림.폴더 }));
505
573
  return a === 'y' || a === 'yes' || a === 'ㅇ';
506
574
  },
507
575
  };
508
576
 
577
+ /*
578
+ * 겪어 본 것 (agent/evolve.js). 쓸수록 이 PC 에 맞춰 나아지는 자리다.
579
+ *
580
+ * 켤 때 두 가지를 받아 온다.
581
+ * · 프롬프트에 실을 몇 줄 — 여기서 되는 명령, 이 모델의 버릇
582
+ * · 지난번에 알아낸 토큰 배수 — 첫 턴부터 제대로 셈한다
583
+ *
584
+ * 둘 다 없으면 아무 일도 안 일어난다. 처음 켠 PC 는 지금과 똑같이 돈다.
585
+ */
586
+ ctx.배움 = new 배움(root, homeDir());
587
+ session.배움요약 = ctx.배움.요약(conn.model);
588
+ const 아는배수 = ctx.배움.아는보정(conn.model);
589
+ if (아는배수) { session.보정 = 아는배수; session.보정잰것 = 1; }
590
+
591
+ /*
592
+ * 모델 카드 (agent/card.js).
593
+ *
594
+ * 겪어 본 버릇을 **하네스 설정으로** 바꾼다. 프롬프트에 "이렇게 해라" 고 적는
595
+ * 것과 다르다 — 작은 모델은 그 말을 잘 안 듣는다. 여기서 바꾸는 것은 deel 의
596
+ * 행동이라 모델의 협조가 필요 없다.
597
+ *
598
+ * 턴마다 다시 만든다. 이번 대화에서 겪는 것이 계속 쌓이므로, 켤 때 한 번
599
+ * 만들어 두면 그 뒤에 알아낸 것이 이번 대화에서는 안 쓰인다.
600
+ */
601
+ ctx.카드다시 = () => {
602
+ ctx.카드 = 카드(conn.model, session.본것, ctx.배움?.현황(conn.model)?.모델);
603
+ return ctx.카드;
604
+ };
605
+ ctx.카드다시();
606
+
607
+ /*
608
+ * 대화 갈래. 연결·도구·되돌리기는 같이 쓰고 오간 말만 여러 벌 갖는다.
609
+ * 갈래마다 저장 파일을 따로 열어서, 나중에 `/sessions` 로 각각 찾아갈 수 있다.
610
+ */
611
+ ctx.갈래 = new Threads(session, ctx, () => new Store(root).begin({ model: conn.model, base: conn.base, root }), store);
612
+
509
613
  // ── 컨텍스트 길이를 모델에서 긁어온다 ─────────────────────────────────
510
614
  //
511
615
  // 켤 때마다 서버에 물어본다. 저장된 값을 그대로 믿지 않는다 —
@@ -554,6 +658,26 @@ export async function chatLoop(opts = {}) {
554
658
  }
555
659
  }
556
660
 
661
+ /*
662
+ * ── 켤 때 도는 글자 모션 ──────────────────────────────────────────────
663
+ *
664
+ * 사람은 `deel` 이라고 치고 들어온다. 그 글자가 그 자리에서 deel-local 로
665
+ * 자라고, 아래로 선이 그어지며 닫힌다. 꾸미기만은 아니다 — 이 프로그램이
666
+ * 하는 말이 딱 하나인데(소스가 이 컴퓨터 밖으로 안 나간다) 그 말을 켜는
667
+ * 첫 1초에 그림으로 한 번 하는 것이다. 바깥 게이트웨이면 같은 선이 노랗게
668
+ * 그어진다. 그러면 사람이 그 자리에서 안다.
669
+ *
670
+ * 여기서 도는 이유는 **연결이 정해진 다음**이라야 색을 제대로 칠하기
671
+ * 때문이다. 더 일찍 돌면 초록으로 그려 놓고 나중에 바깥이었다고 정정하는
672
+ * 꼴이 되는데, 그건 안 보여 주느니만 못하다.
673
+ */
674
+ {
675
+ let 바깥 = false;
676
+ try { 바깥 = !isLocalHost(new URL(conn.base).hostname); } catch { 바깥 = false; }
677
+ say('');
678
+ await 인트로({ 바깥, 곁말: 기본곁말(바깥), 쓰기: (t) => process.stdout.write(t) });
679
+ }
680
+
557
681
  // ── 머리말 ────────────────────────────────────────────────────────────
558
682
  화면.머리말(headerLines(session, found));
559
683
  const warn = [];
@@ -729,7 +853,7 @@ export async function chatLoop(opts = {}) {
729
853
  계획승인받나 = 골라진.겹침 === true;
730
854
  const w = getWork(골라진.mode);
731
855
  say('');
732
- say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')}`
856
+ say(` ${c.hcyan(w.glyph)} ${c.bold(보일이름(w.id))} ${c.gray('(' + w.en + ')')}`
733
857
  + ` ${c.gray('말 속에 ' + 골라진.why + ' 가 있어서')}`
734
858
  + (canWrite(골라진.mode) ? '' : ` ${c.green('· 파일은 안 바꿉니다')}`));
735
859
  // 겹친 요청은 '파일을 안 바꾼다' 로 끝나면 안 된다. 시킨 일의 절반만 한 것이다.
@@ -747,10 +871,24 @@ export async function chatLoop(opts = {}) {
747
871
  const started = Date.now();
748
872
  const before = { in: session.usage.in, out: session.usage.out };
749
873
 
874
+ /*
875
+ * 창 제목에 흐른 시간을 띄운다. 탭 이름만 봐도 도는 중인지 알게 하려는 것이다.
876
+ *
877
+ * 화면에 보이는 글자를 건드리지 않는다 — OSC 는 커서를 안 옮기고 아무것도
878
+ * 안 찍으므로, 한창 그리는 중에 끼어들어도 화면이 깨지지 않는다.
879
+ * unref 를 거는 이유는, 이 시계 하나 때문에 프로그램이 안 꺼지면 안 되기 때문이다.
880
+ */
881
+ 창제목(제목글('도는중', { 폴더: 알림.폴더, 초: 0 }));
882
+ const 제목시계 = setInterval(
883
+ () => 창제목(제목글('도는중', { 폴더: 알림.폴더, 초: (Date.now() - started) / 1000 })),
884
+ 1000,
885
+ );
886
+ 제목시계.unref?.();
887
+
750
888
  // 어디까지 적었는지. 도중에 죽어도 여기까지는 남아 있게 자주 흘려 보낸다.
751
889
  let saved = session.messages.length;
752
890
  const flush = () => {
753
- for (const m of session.messages.slice(saved)) store.append(m);
891
+ for (const m of session.messages.slice(saved)) ctx.갈래.현재store().append(m);
754
892
  saved = session.messages.length;
755
893
  };
756
894
  let tools = 0;
@@ -759,6 +897,18 @@ export async function chatLoop(opts = {}) {
759
897
  let 턴탈났나 = false;
760
898
  let thinkChars = 0;
761
899
  let streamed = false;
900
+ /*
901
+ * 답을 그리는 자리. 턴마다 새로 만든다 — 앞 턴의 코드 울타리 상태가
902
+ * 다음 턴으로 새면 멀쩡한 답이 통째로 코드 블록으로 그려진다.
903
+ */
904
+ const 답그림 = new 마크다운({ 폭: (process.stdout.columns || 80) - 6 });
905
+ // 답 흐름이 끊기는 자리마다 남은 반 줄을 비운다. 안 비우면 마지막 줄이 사라진다.
906
+ const 답비우기 = () => {
907
+ for (const 조각 of 답그림.끝()) {
908
+ if (typeof 조각 === 'string') 화면.붙임(` ${답표시} ${조각}\n`);
909
+ else 화면.붙임(조각.이어붙임 + (조각.끝났나 ? '\n' : ''));
910
+ }
911
+ };
762
912
  let thinkingShown = false;
763
913
  let stage = null;
764
914
  // 접는 중 표시. 끝나거나 다른 글을 찍기 전에 반드시 멈춰야 한다.
@@ -779,6 +929,9 @@ export async function chatLoop(opts = {}) {
779
929
  * 그렇게 날아간다. 테두리를 그대로 두고 안엣것만 바꾸는 이유다.
780
930
  */
781
931
  화면.일시작(session, '생각');
932
+ // 이번 턴에 쓸 카드를 지금 만든다. 앞 턴에서 겪은 것까지 반영돼야
933
+ // '겪을수록 나아진다' 가 이번 대화 안에서도 참이 된다.
934
+ ctx.카드다시?.();
782
935
  try {
783
936
  for await (const ev of run(session, ctx, 보낼글, { signal: turn.signal })) {
784
937
  /*
@@ -830,15 +983,28 @@ export async function chatLoop(opts = {}) {
830
983
  * 세로줄 하나면 된다. 칸을 나눠 그리는 화면으로 갈 이유가 없다 —
831
984
  * 파이프로 넘기거나 기록으로 남길 때도 그대로 읽힌다.
832
985
  */
986
+ /*
987
+ * 답을 마크다운으로 그린다 (ui/md.js).
988
+ *
989
+ * 줄이 끝나야 그릴 수 있다 — `**굵` 까지 왔을 때는 그게 굵은 글씨가
990
+ * 될지 알 수 없고, 한 번 찍은 글자는 되돌릴 수 없다. 그래서 md 가
991
+ * 줄 단위로 모았다가 내놓는다. 줄이 화면 폭보다 길어지면 거기까지를
992
+ * 날것으로 흘려보낸다(md 가 알아서 한다) — 긴 문단에서 몇 초씩
993
+ * 아무것도 안 나오면 멈춘 것처럼 보이기 때문이다.
994
+ */
833
995
  case 'content':
834
996
  clearThinking();
835
- if (!streamed) { streamed = true; 화면.일바꿈('답'); 화면.붙임(` ${답표시} `); }
836
- 화면.붙임(ev.text.replace(/\n/g, `\n ${답표시} `));
997
+ if (!streamed) { streamed = true; 화면.일바꿈('답'); }
998
+ for (const 조각 of 답그림.넣기(ev.text)) {
999
+ if (typeof 조각 === 'string') { 화면.붙임(` ${답표시} ${조각}\n`); continue; }
1000
+ // 아직 안 끝난 긴 줄. 그 줄이 처음 나가는 것이면 세로줄을 앞에 세운다.
1001
+ 화면.붙임((조각.첫조각 ? ` ${답표시} ` : '') + 조각.이어붙임 + (조각.끝났나 ? '\n' : ''));
1002
+ }
837
1003
  break;
838
1004
 
839
1005
  case 'tool_start':
840
1006
  clearThinking();
841
- if (streamed) { say(''); streamed = false; }
1007
+ if (streamed) { 답비우기(); say(''); streamed = false; }
842
1008
  // 문구를 지금 하는 일에 맞춘다. 아무 말이나 돌려 대면 두 번째부터
843
1009
  // 아무도 안 읽고, 그때부터는 화면이 조용한 것과 같아진다.
844
1010
  화면.일바꿈(갈래고르기(ev.name));
@@ -849,7 +1015,7 @@ export async function chatLoop(opts = {}) {
849
1015
  // 여럿을 같이 돌린다 — 한 줄로 알리고, 이름은 결과와 붙여서 그린다.
850
1016
  case 'tools_start':
851
1017
  clearThinking();
852
- if (streamed) { say(''); streamed = false; }
1018
+ if (streamed) { 답비우기(); say(''); streamed = false; }
853
1019
  화면.일바꿈(갈래고르기(ev.names?.[0]));
854
1020
  say('');
855
1021
  say(` ${c.gray(`${ev.count}개를 함께 돌립니다`)} ${c.gray('·')} ${c.gray(ev.names.join(' '))}`);
@@ -861,7 +1027,7 @@ export async function chatLoop(opts = {}) {
861
1027
  // '시작' 을 안 거쳤다. 그래서 '생각 중…' 줄이 안 지워진 채로 결과가
862
1028
  // 그 줄 뒤에 가서 붙고, 이름도 없이 "└ 인자가 잘렸습니다" 만 남는다.
863
1029
  // 무슨 도구가 왜 그랬는지 알 수 없는 화면이 된다.
864
- if (ev.showLabel) { clearThinking(); if (streamed) { say(''); streamed = false; } say(''); }
1030
+ if (ev.showLabel) { clearThinking(); if (streamed) { 답비우기(); say(''); streamed = false; } say(''); }
865
1031
  // 같이 돈 것은 이름을 다시 적어 준다. 안 그러면 어느 결과인지 모른다.
866
1032
  if (ev.parallel || ev.showLabel) say(` ${toolLabel(ev.name, ev.args)}`);
867
1033
  if (ev.name === 'TodoWrite' && ev.result?.todos) {
@@ -872,6 +1038,19 @@ export async function chatLoop(opts = {}) {
872
1038
  }
873
1039
  } else {
874
1040
  say(` ${toolResultLine(ev.result, ev.ms ?? 0)}`);
1041
+ /*
1042
+ * 비밀이 지나갔으면 반드시 말한다.
1043
+ *
1044
+ * 가렸으면 가렸다고, 못 가렸으면 못 가렸다고 적는다. 파일에서
1045
+ * 읽어 온 글은 일부러 안 가린다 — 가리면 모델이 그 가린 글을
1046
+ * 되돌려 써서 진짜 열쇠가 지워진다. 대신 사람에게 알린다.
1047
+ * 조용히 넘어가면 사람은 제 열쇠가 대화에 들어간 줄을 모른다.
1048
+ */
1049
+ if (ev.비밀) {
1050
+ say(ev.비밀.가렸나
1051
+ ? ` ${c.gray(`⊘ ${ev.비밀.말} — 모델에 가려서 보냈습니다`)}`
1052
+ : ` ${c.yellow('!')} ${c.yellow(ev.비밀.말)} ${c.gray('— 파일 내용은 가리지 않습니다 (가리면 되돌려 쓸 때 지워집니다)')}`);
1053
+ }
875
1054
  /*
876
1055
  * 파일을 바꾸는 명령이었으면, 무엇을 떠 뒀는지 적는다.
877
1056
  *
@@ -879,6 +1058,18 @@ export async function chatLoop(opts = {}) {
879
1058
  * 수 있는지 없는지 알 길이 없어서, 사람은 되돌릴 수 있는 줄 알고
880
1059
  * 넘어가거나 반대로 못 되돌리는 줄 알고 겁을 낸다. 사실을 적는다.
881
1060
  */
1061
+ /*
1062
+ * 확인한 것을 센다. 상태줄이 이 숫자를 본다.
1063
+ *
1064
+ * 모델이 "다 됐습니다" 로 답을 맺는 것과 **실제로 돌려 본 것**은
1065
+ * 다른 일이다. 화면에 초록 ✓ 가 없으면 아직 아무도 안 돌려 본
1066
+ * 것이고, 그 구분이 상태줄에 있어야 사람이 속지 않는다.
1067
+ */
1068
+ if (ev.name === 'Verify' && ev.result) {
1069
+ session.검증.돈횟수 += 1;
1070
+ session.검증.확인 += ev.result.확인됨 ?? 0;
1071
+ session.검증.탈 += ev.result.탈 ?? 0;
1072
+ }
882
1073
  if (ev.result?.되돌릴것?.length) {
883
1074
  const 것들 = ev.result.되돌릴것;
884
1075
  say(` ${c.gray(`↩ ${것들.slice(0, 3).join(' · ')}${것들.length > 3 ? ` 외 ${것들.length - 3}개` : ''} 는 떠 뒀습니다 — /undo 로 되돌아갑니다`)}`);
@@ -936,17 +1127,29 @@ export async function chatLoop(opts = {}) {
936
1127
  */
937
1128
  case 'task_start':
938
1129
  clearThinking();
939
- if (streamed) { say(''); streamed = false; }
1130
+ if (streamed) { 답비우기(); say(''); streamed = false; }
940
1131
  화면.일바꿈('하위', clip(ev.목적, 24));
941
1132
  say('');
942
1133
  say(` ${c.hmagenta('⌥')} ${c.bold('하위 작업')} ${c.white(clip(ev.목적, 60))}`
943
- + ` ${c.gray(`· ${getWork(ev.모드).name} · 최대 ${ev.steps}걸음`)}`);
1134
+ + ` ${c.gray(`· ${보일이름(ev.모드)} · 최대 ${ev.steps}걸음`)}`);
944
1135
  say(` ${c.gray('여기서부터는 따로 떨어진 대화입니다 — 결과 요약만 위로 올라옵니다.')}`);
1136
+ /*
1137
+ * 다른 모델에게 떼어 줬으면 여기서 말한다.
1138
+ *
1139
+ * 상태줄의 ⌂ 는 이 세션의 연결을 보고 있어서 하위가 딴 데로
1140
+ * 나가는 것을 모른다. 이 줄이 그 사실을 남기는 유일한 자리다.
1141
+ * 바깥으로 나가면 노랗게 — 그냥 지나칠 수 없게.
1142
+ */
1143
+ if (ev.모델) {
1144
+ say(ev.밖으로
1145
+ ? ` ${c.hyellow('↗')} ${c.hyellow(ev.모델)} ${c.gray('— 이 덩이는 바깥으로 나갑니다')}`
1146
+ : ` ${c.hgreen('⌂')} ${c.gray(`${ev.모델} — 이 컴퓨터 안입니다`)}`);
1147
+ }
945
1148
  break;
946
1149
 
947
1150
  case 'task_done': {
948
1151
  clearThinking();
949
- if (streamed) { say(''); streamed = false; }
1152
+ if (streamed) { 답비우기(); say(''); streamed = false; }
950
1153
  const 끝 = ev.끝 ?? {};
951
1154
  const 잘됨 = 끝.type === 'done';
952
1155
  const 왜 = { done: '끝냈습니다', limit: '걸음 수를 다 써서 멈췄습니다 — 다 못 했습니다',
@@ -954,7 +1157,7 @@ export async function chatLoop(opts = {}) {
954
1157
  aborted: '중단했습니다' }[끝.type] ?? '끝난 이유를 알 수 없습니다';
955
1158
  say('');
956
1159
  say(` ${잘됨 ? c.green('✓') : c.yellow('⚠')} ${c.gray('하위 작업')} ${c.white(clip(ev.목적, 50))}`
957
- + ` ${c.gray(`— ${왜}`)} ${c.gray(`(${끝.steps ?? 0}걸음)`)}`);
1160
+ + ` ${c.gray(`— ${왜}`)} ${c.gray(`(${끝.steps ?? 0}걸음${ev.모델 ? ` · ${ev.모델}` : ''})`)}`);
958
1161
  // 무엇이 실제로 생겼는지는 하위가 한 말이 아니라 디스크가 말한다.
959
1162
  만든파일보이기(끝.files);
960
1163
  if (끝.why) say(` ${c.gray(`막힌 데: ${clip(끝.why, 80)}`)}`);
@@ -981,6 +1184,30 @@ export async function chatLoop(opts = {}) {
981
1184
  접는중 = true;
982
1185
  break;
983
1186
 
1187
+ /*
1188
+ * 요약 압축보다 먼저 오는 것. 대화는 안 건드리고 옛 도구 결과만 접었다.
1189
+ * 조용히 하면 사람은 어느 파일 내용이 왜 사라졌는지 모른다 — 한 줄로 알린다.
1190
+ */
1191
+ case 'folded': {
1192
+ clearThinking();
1193
+ say(` ${c.cyan('◲')} ${c.gray(`오래된 도구 결과 ${ev.접은것}개를 접었습니다 — `)}`
1194
+ + `${c.white(ev.아낀토큰.toLocaleString())} ${c.gray('토큰을 비웠습니다. 대화는 그대로입니다.')}`);
1195
+ /*
1196
+ * 무엇을 접었는지 이름으로 보여 준다.
1197
+ *
1198
+ * 조용히 버리면 "아까 그 파일 내용 어디 갔지" 를 사람이 스스로 답할 수
1199
+ * 없다. 개발자 수준에서만 편다 — 쉬움 수준에서는 숫자 한 줄이면 된다.
1200
+ */
1201
+ const 접은것들 = ev.접은것들 ?? [];
1202
+ if (접은것들.length && session.level !== '쉬움') {
1203
+ const 보일것 = 접은것들.slice(0, 4)
1204
+ .map((x) => `${x.도구}${x.곳 ? `(${x.곳})` : ''}`).join(' · ');
1205
+ const 남은수 = 접은것들.length - Math.min(4, 접은것들.length);
1206
+ say(` ${c.gray(`${보일것}${남은수 > 0 ? ` 외 ${남은수}개` : ''}`)}`);
1207
+ }
1208
+ break;
1209
+ }
1210
+
984
1211
  case 'compacted': {
985
1212
  접기멈춤();
986
1213
  const 줄인 = ev.before - ev.after;
@@ -989,7 +1216,7 @@ export async function chatLoop(opts = {}) {
989
1216
  `${c.green(`(${Math.round((줄인 / Math.max(1, ev.before)) * 100)}% 줄어듦)`)}`);
990
1217
  if (ev.fallback) say(` ${c.yellow('요약을 못 받아 그냥 줄였습니다.')}`);
991
1218
  // 접히면 이력이 통째로 바뀐다. 덧붙이기로는 못 맞추니 새로 적는다.
992
- store.replace(session.messages, `압축 — ${ev.folded}개를 요약으로`);
1219
+ ctx.갈래.현재store().replace(session.messages, `압축 — ${ev.folded}개를 요약으로`);
993
1220
  saved = session.messages.length;
994
1221
  break;
995
1222
  }
@@ -1033,7 +1260,7 @@ export async function chatLoop(opts = {}) {
1033
1260
  case 'stuck':
1034
1261
  턴탈났나 = true;
1035
1262
  clearThinking();
1036
- if (streamed) { say(''); streamed = false; }
1263
+ if (streamed) { 답비우기(); say(''); streamed = false; }
1037
1264
  say('');
1038
1265
  say(` ${c.yellow('⊘')} ${c.bold('같은 자리에서 헛돌고 있어 멈췄습니다.')}`);
1039
1266
  say(` ${c.gray(ev.why)}`);
@@ -1055,7 +1282,7 @@ export async function chatLoop(opts = {}) {
1055
1282
  case 'aborted':
1056
1283
  턴탈났나 = true;
1057
1284
  clearThinking();
1058
- if (streamed) { say(''); streamed = false; }
1285
+ if (streamed) { 답비우기(); say(''); streamed = false; }
1059
1286
  say('');
1060
1287
  // 남았는지 아닌지를 **사실대로** 말한다. 전에는 무조건 '남아 있다' 고 했는데
1061
1288
  // 실제로는 아무것도 안 남는 경우가 있었다. 그러면 "이어서 해줘" 라고 했을 때
@@ -1074,7 +1301,7 @@ export async function chatLoop(opts = {}) {
1074
1301
 
1075
1302
  case 'done':
1076
1303
  clearThinking();
1077
- if (streamed) say('');
1304
+ if (streamed) { 답비우기(); say(''); }
1078
1305
  만든파일보이기(ev.files);
1079
1306
  break;
1080
1307
  }
@@ -1090,6 +1317,22 @@ export async function chatLoop(opts = {}) {
1090
1317
  // 어떻게 끝났든 일하는 표시는 반드시 걷는다. 오류로 빠져나온 길에서
1091
1318
  // 안 걷으면 돌아가는 표시가 화면에 붙박이로 남고, 시계도 계속 돈다.
1092
1319
  화면.일끝();
1320
+ clearInterval(제목시계);
1321
+ /*
1322
+ * 오래 걸린 턴만 알린다. "안녕" 에 1초 만에 답할 때마다 딩 소리가 나면
1323
+ * 사람은 이틀 만에 알림을 꺼 버리고, 그러면 정작 3분짜리 턴도 못 듣는다.
1324
+ * 알림은 아껴 써야 알림이다. (문턱은 ui/notify.js 의 알릴만한초)
1325
+ */
1326
+ /*
1327
+ * 되돌릴 수 있는 턴 수를 여기서 한 번 센다.
1328
+ *
1329
+ * 상태줄이 직접 세게 하면 안 된다 — 상태줄은 사람이 글자 하나 칠 때마다
1330
+ * 다시 그려지고, 거기서 이력 파일을 열면 타이핑이 끊긴다. 턴 경계에서
1331
+ * 한 번 세어 두면 화면은 그 숫자만 읽으면 된다.
1332
+ */
1333
+ try { session.되돌릴턴 = ctx.history.turns().length; } catch { /* 못 세면 그냥 둔다 */ }
1334
+ if (알릴까({ 걸린밀리초: Date.now() - started, 켬: 알림.켬 })) 종();
1335
+ 창제목(제목글(턴탈났나 ? '탈남' : '끝남', { 폴더: 알림.폴더 }));
1093
1336
  turn = null;
1094
1337
  interrupted = false; // 중단은 '끝내기' 의사가 아니다. 종료 카운트를 되돌린다.
1095
1338
  flush(); // 오류로 끝났어도 여기까지는 남긴다
@@ -1119,6 +1362,9 @@ export async function chatLoop(opts = {}) {
1119
1362
  say('');
1120
1363
  계획상자(할일);
1121
1364
  say('');
1365
+ // 계획을 다 내놓고 사람 답을 기다리는 자리다. 위 confirm 과 같은 이유로 알린다.
1366
+ if (알릴까({ 물어봄: true, 켬: 알림.켬 })) 종();
1367
+ 창제목(제목글('물어봄', { 폴더: 알림.폴더 }));
1122
1368
  const 답 = String(await ask(
1123
1369
  `이대로 진행할까요? ${c.gray('⏎ 진행 · n 취소 · 그 밖엔 고칠 점')}`,
1124
1370
  { def: 'y' },
@@ -1145,9 +1391,14 @@ export async function chatLoop(opts = {}) {
1145
1391
  }
1146
1392
 
1147
1393
  rl.close();
1394
+ // 남의 터미널을 우리 이름으로 두고 나가지 않는다. 켤 때 있던 제목으로 돌려놓는다.
1395
+ 제목되돌리기();
1148
1396
  // 띄운 남의 프로세스는 반드시 거둔다. 안 거두면 deel 을 껐는데도
1149
1397
  // 그 서버가 계속 돌고 있게 된다 — 사람 눈에는 안 보이는 채로.
1150
1398
  for (const s of mcp붙임.서버들) s.닫기();
1399
+ // 언어 서버도 같이 거둔다. 이건 사람 눈에 안 보이는 채로 메모리를 몇백 MB
1400
+ // 물고 있는 종류라, 남겨 두면 나중에 작업 관리자를 열기 전에는 모른다.
1401
+ 언어서버다끄기().catch(() => {});
1151
1402
  // 뒤에서 돌던 명령도 같이 거둔다. 이걸 조용히 하면 안 된다 —
1152
1403
  // 사람은 dev 서버가 아직 떠 있다고 여기고 브라우저를 새로 고치다가
1153
1404
  // "왜 안 되지" 로 시간을 쓴다. 몇 개를 껐는지 말해 준다.
@@ -72,12 +72,47 @@ const BLOCKED = [
72
72
  { re: /\brm\s+(-[a-z]*[rR][a-z]*f|-[a-z]*f[a-z]*[rR])\b[^|;&]*\s(\/|~|\$HOME)\s*$/i, why: '뿌리 폴더를 통째로 지우려 합니다' },
73
73
  { re: /\b(mkfs|fdisk|diskpart)\b/i, why: '디스크를 초기화하는 명령입니다' },
74
74
  { re: /\bformat\s+[a-z]:/i, why: '드라이브를 포맷하는 명령입니다' },
75
- { re: /\b(rd|rmdir)\s+\/s\b/i, why: '폴더를 통째로 지웁니다 정션이 있으면 원본까지 딸려 갑니다' },
76
- { re: /\bdel\s+\/[sq]\b.*\\\*/i, why: '하위 폴더까지 전부 지웁니다' },
75
+ // 윈도우 짝을 빠뜨리면 된다deel 윈도우에서 주로 돈다.
76
+ //
77
+ // 옛 규칙은 스위치가 **차례대로** 오고 끝에 `\*` 가 붙은 모양만 봤다.
78
+ // 그래서 `del /f /s /q C:\` 와 `rd /q /s C:\` 가 그대로 새어 나갔다.
79
+ // 스위치는 아무 차례로나 오므로 차례를 안 따진다.
80
+ { re: /\b(rd|rmdir)\b[^|;&]*\s\/s\b/i, why: '폴더를 통째로 지웁니다 — 정션이 있으면 원본까지 딸려 갑니다' },
81
+ { re: /\bdel\b[^|;&]*\s\/[sq]\b[^|;&]*(\\\*|\s[a-z]:\\?\s*$)/i, why: '하위 폴더까지 전부 지웁니다' },
82
+ // 파워셸도 같은 일을 한다. 드라이브 뿌리나 집 폴더를 통째로 미는 것만 본다 —
83
+ // 폴더 하나 지우는 평범한 Remove-Item 까지 막으면 도구를 못 쓴다.
84
+ {
85
+ re: /Remove-Item\b[^|;&]*\s-(Recurse|r)\b[^|;&]*\s(-?[a-z]*\s*)*([a-z]:\\?|~|\$HOME|\$env:USERPROFILE)\s*$/i,
86
+ why: '드라이브나 집 폴더를 통째로 지웁니다',
87
+ },
77
88
  { re: /git\s+push\b[^|;&]*--force(?!-with-lease)/i, why: '원격 이력을 덮어씁니다 (--force-with-lease 를 쓰세요)' },
78
89
  // 낱말만 보면 안 된다. `node scripts/shutdown.js` 나 `npm run reboot` 이
79
90
  // '시스템을 끕니다' 로 막히고 감사기록에까지 남았다. 명령의 **첫 낱말**일 때만 본다.
80
91
  { re: /(^|[|;&]\s*)(sudo\s+)?(shutdown|reboot|halt|poweroff)\b/i, why: '시스템을 끕니다' },
92
+ // 포크 폭탄.
93
+ //
94
+ // 껐다 켜면 기계는 돌아오니 '되돌릴 수 없는 것' 은 아니다. 그런데 되돌아오지
95
+ // 않는 것이 하나 있다 — **아직 안 적은 것들.** 기계가 굳으면 열어 둔 것을
96
+ // 저장할 수도, 하던 대화를 끝낼 수도 없다. 그래서 목록에 넣는다.
97
+ //
98
+ // 이름은 아무거나 될 수 있으니(`:` 든 `bomb` 든) 이름 자체를 보지 않고 **모양**을
99
+ // 본다: 함수를 만들고, 그 안에서 **저를 불러 곧바로 파이프나 뒤로 보내기에**
100
+ // 물리고, 밖에서 그 이름을 부른다. 뒷갈이(\2)로 같은 이름인지까지 본다.
101
+ //
102
+ // '저를 부른 **바로 뒤**가 | 나 &' 인 것이 열쇠다. 그냥 '이름이 몸 안에 있고
103
+ // & 도 있다' 로 보면 `up(){ docker compose up -d & }; up` 같은 평범한 함수가
104
+ // 걸린다 — 거기서는 up 뒤에 `-d` 가 온다.
105
+ //
106
+ // 이름 앞뒤로 낱말 경계도 본다. 그래야 `tee -a out.log` 의 log 나
107
+ // `deploy_app` 의 deploy 를 저를 부르는 것으로 잘못 읽지 않는다.
108
+ // 앞자리에 따옴표와 여는 괄호도 넣는다. 안 넣으면 `bash -c ":(){ :|:& };:"` 처럼
109
+ // 한 겹 싸는 것만으로 그대로 새어 나간다.
110
+ {
111
+ re: /(^|[|;&\s"'(])([\w.:]{1,16})\s*\(\s*\)\s*\{[^}]*(?<![\w.])\2(?![\w.])\s*[|&][^}]*\}\s*;?\s*(?<![\w.])\2(?![\w.])/,
112
+ why: '스스로를 끝없이 불려 기계를 멈춰 세웁니다 (포크 폭탄)',
113
+ },
114
+ // 같은 것을 한 줄로 적는 오래된 방법. 이 모양으로 쓸 다른 일이 없다.
115
+ { re: /\bfork\s+while\s+fork\b/i, why: '스스로를 끝없이 불려 기계를 멈춰 세웁니다 (포크 폭탄)' },
81
116
  { re: /curl[^|]*\|\s*(ba)?sh/i, why: '받은 스크립트를 그대로 실행합니다' },
82
117
  { re: /\biwr\b[^|]*\|\s*iex\b/i, why: '받은 스크립트를 그대로 실행합니다' },
83
118
  ];