deel-local-cli 2.1.0 → 2.1.2

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/README.ko.md CHANGED
@@ -323,7 +323,7 @@ deel --offline
323
323
  무엇이 어디로 갈 수 있는지는 켤 때 화면 맨 위에 늘 적혀 있습니다.
324
324
 
325
325
  ```
326
- deel 2.1.0 ⌂ 이 안
326
+ deel 2.1.2 ⌂ 이 안
327
327
  보냄 이 컴퓨터 안 127.0.0.1:11434 ← 여기 말고는 어디로도 안 갑니다
328
328
  ```
329
329
 
@@ -591,7 +591,7 @@ LM Studio 는 `/api/v0/models`, llama.cpp 는 `/props`. 못 알아보면 `(추
591
591
  </picture>
592
592
 
593
593
  무슨 일을 하는 중인지에 따라 **줄 수 있는 도구와 추론 설정이 같이 바뀝니다.**
594
- `Shift+Tab` 으로 차례로 돌리거나, 이름을 그대로 치면 됩니다.
594
+ `Ctrl+O` 로 차례로 돌리거나, 이름을 그대로 치면 됩니다. (`Shift+Tab` 은 승인 방식을 돌립니다.)
595
595
 
596
596
  | 모드 | 하는 일 | 파일을 고치나 | 추론 |
597
597
  |---|---|---|---|
@@ -1142,7 +1142,7 @@ AWS 키 · Google 키 · JWT · 주소에 박힌 열쇠 · `Authorization` 계
1142
1142
  | 모드 | 언제 물어보나 |
1143
1143
  |---|---|
1144
1144
  | `auto` (기본) | 안 물어봄 — **맡기는 모드**. 되돌리기가 안전망. 모델이 되묻거나 계획부터 내도 60초 안에 답이 없으면 알아서 진행(`askWait`) |
1145
- | `confirm` | 되돌릴 수 없는 명령만 |
1145
+ | `confirm` | 되돌릴 수 없는 명령, 그리고 MCP 도구(서버가 읽기만 한다고 적은 것은 빼고) |
1146
1146
  | `strict` | 파일 변경·명령 전부. **바뀔 내용(diff)을 먼저 보여 주고** 묻습니다 |
1147
1147
 
1148
1148
  회사에서 「바꾸기 전에 늘 사람이 본다」 를 정해야 하면 관리 정책에 `"approval": "strict"` 한 줄을
@@ -1151,7 +1151,7 @@ AWS 키 · Google 키 · JWT · 주소에 박힌 열쇠 · `Authorization` 계
1151
1151
 
1152
1152
  **「다 됐다」 는 검사가 정합니다.** 설정에 `"check": "npm test"` 를 적거나 `deel run --check` 로 주면,
1153
1153
  모델이 무엇을 바꾸고 끝내려 할 때 deel 이 그 검사를 **직접** 돌립니다. 실패하면 출력을 돌려주고 이어서
1154
- 고치게 하고(기본 3판), 끝내 실패면 실패로 끝냅니다 — `deel run` 은 종료코드 8. 검사 명령도 모델의
1154
+ 고치게 하고(검사는 기본 세 번까지), 끝내 실패면 실패로 끝냅니다 — `deel run` 은 종료코드 8. 검사 명령도 모델의
1155
1155
  명령과 같은 관문(규칙·훅·승인·위험 명령 차단)을 지납니다 — [완료 검사](docs/ko/company.md#완료-검사).
1156
1156
 
1157
1157
  **얼마나 해내나는 골든셋으로 잽니다.** `deel eval --init` 이 예제 과제 다섯을 만들고, `deel eval` 이
@@ -1333,7 +1333,9 @@ zip 은 진짜 `unzip` 으로, tar 는 진짜 `tar` 가 만든 것을 읽혀 교
1333
1333
 
1334
1334
  | 판 | 무엇이 바뀌었나 |
1335
1335
  |---|---|
1336
- | **[2.1.0](docs/ko/releases/2.1.md#210)** | 맡기면 멈추지 않고, 묻게 하면 diff 를 보여 줍니다 — 「다 됐다」 는 검사가 정합니다(`check`) · 얼마나 해내나는 골든셋으로 잽니다(`deel eval`) · 관리 정책 `"approval"` 로 회사가 승인 바닥을 겁니다 |
1336
+ | **[2.1.2](docs/ko/releases/2.1.md#212)** | 정책 · 작은 공백 · 속도 — confirm 이 읽기만 하지 않는 MCP 도구를 묻고, 에디터(ACP) 세션은 16개를 넘으면 내려놓았다가 다음 말에 되살리고, `--check-rounds`·`--check-timeout` 과 과제별 검사 설정이 `deel eval` 에 생겼고, 시작 때 읽는 파일이 164개에서 18개로 줄었습니다 |
1337
+ | [2.1.1](docs/ko/releases/2.1.md#211) | 전체 검수에서 나온 것 — 한 칸 위를 물으면 `config explain` 이 열쇠를 보였고, 웹훅 주소·Sentry DSN 이 셸 환경 거르기와 가리기를 둘 다 지났고, `kill` 로 strict 를 지나 Jobs 서버를 껐고, Def·Refs 가 EUC-KR 을 못 읽었습니다 · `deel eval` 고침 |
1338
+ | [2.1.0](docs/ko/releases/2.1.md#210) | 맡기면 멈추지 않고, 묻게 하면 diff 를 보여 줍니다 — 「다 됐다」 는 검사가 정합니다(`check`) · 얼마나 해내나는 골든셋으로 잽니다(`deel eval`) · 관리 정책 `"approval"` 로 회사가 승인 바닥을 겁니다 |
1337
1339
  | [2.0.3](docs/ko/releases/2.0.md#203) | 2.0.2 는 npm 에 못 나갔습니다 — 이제 GitHub 릴리스는 npm 에 올라간 뒤에만 생깁니다 |
1338
1340
  | [2.0.2](docs/ko/releases/2.0.md#202) | 2.0.1 까지 넘겨 둔 것을 다 잡았습니다 — `deel run` 에만 없던 MCP 도구, 끝난 명령의 번호로 가던 `taskkill`, 한 번 넘어지면 세션 내내 못 쓰던 MCP 서버 |
1339
1341
  | [2.0.1](docs/ko/releases/2.0.md#201) | 울타리 하나가 안 걸렸습니다 — `sudo -u root bash` 가 `curl … \| bash` 막이를 지나갔습니다 — 그리고 관문 넷이 빨개질 수 없었습니다 |
package/README.md CHANGED
@@ -396,7 +396,7 @@ deel --offline
396
396
  The destination is printed at the top of every session:
397
397
 
398
398
  ```
399
- deel 2.1.0 ⌂ inside
399
+ deel 2.1.2 ⌂ inside
400
400
  Sends to this machine 127.0.0.1:11434 ← nowhere else
401
401
  ```
402
402
 
@@ -660,7 +660,7 @@ Korean IME composition, paste, `Ctrl+A/E` and backspace all keep working.
660
660
  </picture>
661
661
 
662
662
  What you are working on changes **which tools the model is given and how hard it thinks.**
663
- Cycle with `Shift+Tab`, or type the name.
663
+ Cycle with `Ctrl+O`, or type the name. (`Shift+Tab` cycles the approval policy.)
664
664
 
665
665
  | Mode | For | Can edit files | Reasoning |
666
666
  |---|---|---|---|
@@ -1244,7 +1244,7 @@ does not ask.
1244
1244
  | Mode | Asks when |
1245
1245
  |---|---|
1246
1246
  | `auto` (default) | Never — **the hand-off mode**; undo is the safety net. If the model asks a question or offers a plan first, it carries on by itself after 60 seconds with no answer (`askWait`) |
1247
- | `confirm` | Irreversible commands only |
1247
+ | `confirm` | Irreversible commands, and MCP tools unless the server marks a tool read-only |
1248
1248
  | `strict` | All file changes and commands. **Shows the change (a diff) first**, then asks |
1249
1249
 
1250
1250
  If your organisation needs "a person sees every change before it lands", put `"approval": "strict"`
@@ -1253,7 +1253,7 @@ floor — see [Rolling it out at work](docs/en/company.md).
1253
1253
 
1254
1254
  **A check, not the model, says when it is done.** Put `"check": "npm test"` in the config or pass
1255
1255
  `deel run --check`, and when the model has changed something and tries to finish, deel runs that check
1256
- **itself**. A failure goes back to the model to keep fixing (3 rounds by default); still failing ends as
1256
+ **itself**. A failure goes back to the model to keep fixing (the check runs up to 3 times by default); still failing ends as
1257
1257
  failed — `deel run` exits with 8. The check goes through the same gates as the model's own commands
1258
1258
  (rules, hooks, approval, the dangerous-command guard) — see [Completion check](docs/en/company.md#completion-check).
1259
1259
 
@@ -1415,7 +1415,9 @@ so one run tells you everything.
1415
1415
 
1416
1416
  | Version | What changed |
1417
1417
  |---|---|
1418
- | **[2.1.0](docs/en/releases/2.1.md#210)** | Hand it off and it keeps going; make it ask and it shows the diff — a check decides when it is done (`check`), a golden set measures how much gets done (`deel eval`), and `"approval"` in the managed policy lets a company set an approval floor |
1418
+ | **[2.1.2](docs/en/releases/2.1.md#212)** | Policy, small gaps and speed — confirm asks before MCP tools that are not read-only, editor (ACP) sessions past 16 are let go and brought back on the next message, `--check-rounds`/`--check-timeout` and per-task check settings in `deel eval`, and startup reads 18 files instead of 164 |
1419
+ | [2.1.1](docs/en/releases/2.1.md#211) | From a full review — `config explain` one level up showed keys, webhook URLs and Sentry DSNs passed both the shell-env filter and masking, `kill` stopped a Jobs server past strict, Def/Refs could not read EUC-KR, and `deel eval` fixes |
1420
+ | [2.1.0](docs/en/releases/2.1.md#210) | Hand it off and it keeps going; make it ask and it shows the diff — a check decides when it is done (`check`), a golden set measures how much gets done (`deel eval`), and `"approval"` in the managed policy lets a company set an approval floor |
1419
1421
  | [2.0.3](docs/en/releases/2.0.md#203) | 2.0.2 never reached npm — GitHub releases now appear only after npm has the version |
1420
1422
  | [2.0.2](docs/en/releases/2.0.md#202) | Everything 2.0.1 left open, closed — MCP tools missing only from `deel run`, `taskkill` sent to an exited command's pid, MCP servers that stayed down for the session after one crash |
1421
1423
  | [2.0.1](docs/en/releases/2.0.md#201) | One fence in 2.0.0 did not hold — `sudo -u root bash` walked through the `curl … \| bash` guard — and four gates could not go red |
package/bin/deel.js CHANGED
@@ -3,27 +3,22 @@
3
3
  import { join, resolve } from 'node:path';
4
4
  import { readFileSync, writeFileSync, statSync } from 'node:fs';
5
5
  import { c, say, mark, rule, clip, width } from '../src/ui/ansi.js';
6
- import { runSetup, runDiagnose, showStatus, banner } from '../src/setup.js';
7
- import { chatLoop } from '../src/repl.js';
8
- import { runOnce, EXIT, 실패덩이 } from '../src/oneshot.js';
9
- import { packSelf, audit, reviewSheet } from '../src/pack/selfpack.js';
10
- import { sbom, 심사명세, 명세요약 } from '../src/pack/sbom.js';
11
- import { runScan } from '../src/backend/scanui.js';
12
- import { closeConnections } from '../src/backend/http.js';
13
- import { parseSize } from '../src/backend/ctxsize.js';
14
- import { runSessions } from '../src/agent/sessionui.js';
15
- import { acp } from '../src/acp/serve.js';
16
- import { runCompletion } from '../src/completion.js';
17
- import { runReset } from '../src/reset.js';
18
- import { 마크다운, 읽는갈래 } from '../src/tools/doc2md.js';
6
+ import { banner } from '../src/ui/banner.js';
7
+ import { EXIT, 실패덩이, closeConnections } from '../src/exit.js';
19
8
  import { 언어잡기, 언어, 말 } from '../src/i18n/index.js';
20
9
  import { 믿기, 안믿기, 믿는목록, 프로젝트금지칸, 너무넓은자리 as 넓은자리 } from '../src/safety/trust.js';
21
10
  import { load as 설정읽기, homeDir, 이PC설정값 } from '../src/config.js';
22
11
  import { 규칙모으기, 어떻게할까, 확인목록, 확인인자, 확인돌리기 } from '../src/safety/policy.js';
23
12
  import { 기록자리, 세기, 도구차례, 막힘차례, 셈JSON } from '../src/stats.js';
24
- import { 진찰 } from '../src/doctor.js';
25
- import { 설명, 설명줄들 } from '../src/configexplain.js';
26
- import { runEval } from '../src/eval/run.js';
13
+
14
+ /*
15
+ * ── 무거운 것은 그 명령을 칠 때 불러온다 (2.1.2) ─────────────────────────
16
+ *
17
+ * 여기 맨 위에서 대화 화면·한 번 돌리기·에디터 서버·평가·반입 묶음을 다 불러와, `deel --version`
18
+ * 도 `deel trust` 도 파일 164개(4.4MB)를 읽고 나서야 첫 줄을 냈다. 맨 위에는 모든 명령이 쓰는
19
+ * 가벼운 것(화면 글·말·설정·신뢰·규칙)만 두고, 나머지는 쓰는 자리에서 `await import()` 한다.
20
+ * test/cli.test.js 가 맨 위에서 닿는 파일 수를 못 박는다.
21
+ */
27
22
 
28
23
  const MIN_NODE = 20;
29
24
 
@@ -60,7 +55,8 @@ function 말정하기() {
60
55
  }
61
56
 
62
57
  // 사내 반입용 묶음 만들기.
63
- function runPack(flags) {
58
+ async function runPack(flags) {
59
+ const { packSelf } = await import('../src/pack/selfpack.js');
64
60
  const 한국어 = 언어() === 'ko';
65
61
  const out = flags.out ? String(flags.out) : join(process.cwd(), 한국어 ? 'deel-반입.zip' : 'deel-import.zip');
66
62
  const r = packSelf(out);
@@ -112,7 +108,7 @@ function runPack(flags) {
112
108
  * 적어 놨는데 안 먹는다」 가 흔하다. 그때 사람은 그 파일만 고치다가 설정이라는
113
109
  * 것을 안 믿게 된다. 층을 열어 보여 주면 5초에 끝나는 일이다.
114
110
  */
115
- function runConfig(args, flags) {
111
+ async function runConfig(args, flags) {
116
112
  const 무엇 = String(args[0] ?? '');
117
113
  const 칸 = String(args[1] ?? '');
118
114
  if (무엇 !== 'explain' || !칸) {
@@ -122,6 +118,7 @@ function runConfig(args, flags) {
122
118
  say('');
123
119
  return 1;
124
120
  }
121
+ const { 설명, 설명줄들 } = await import('../src/configexplain.js');
125
122
  const r = 설명(칸, { root: flags.root ? String(flags.root) : process.cwd() });
126
123
  /*
127
124
  * 찾았나 못 찾았나를 **끝값으로도** 말한다.
@@ -161,6 +158,7 @@ async function runDoctor(flags) {
161
158
  await import('../src/config.js');
162
159
  const { 지금모드, 바깥인가, 나갈수있나, 봉인됐나 } = await import('../src/safety/runmode.js');
163
160
  const { allowEndpoint } = await import('../src/safety/network.js');
161
+ const { 진찰 } = await import('../src/doctor.js');
164
162
 
165
163
  banner();
166
164
  let cfg = null;
@@ -514,7 +512,8 @@ function runRules(args, flags) {
514
512
  }
515
513
 
516
514
  // 묶지 않고 심사 내용만 보기.
517
- function runAudit() {
515
+ async function runAudit() {
516
+ const { audit, reviewSheet } = await import('../src/pack/selfpack.js');
518
517
  say('');
519
518
  say(reviewSheet(audit(), new Date().toISOString().replace('T', ' ').slice(0, 19)));
520
519
  return 0;
@@ -536,7 +535,8 @@ function runAudit() {
536
535
  * `--out` 이 없으면 표준출력으로 낸다. 파이프에 바로 물리는 자리라
537
536
  * 여기서는 say() 를 안 쓴다 (completion 과 같은 규칙이다).
538
537
  */
539
- function runDoc2md(args, flags) {
538
+ async function runDoc2md(args, flags) {
539
+ const { 마크다운, 읽는갈래 } = await import('../src/tools/doc2md.js');
540
540
  const 파일 = args[0];
541
541
  if (!파일) {
542
542
  say(` ${mark.warn} 바꿀 파일을 주세요: ${c.white('deel doc2md 보고서.pptx')}`);
@@ -568,7 +568,7 @@ function runDoc2md(args, flags) {
568
568
  return 0;
569
569
  }
570
570
 
571
- function runSbom(flags) {
571
+ async function runSbom(flags) {
572
572
  const 어느것 = String(flags.only ?? '').toLowerCase();
573
573
  /*
574
574
  * 모르는 `--only` 는 멈춘다.
@@ -581,6 +581,8 @@ function runSbom(flags) {
581
581
  process.stderr.write(`\n --only 에는 sbom · 명세(spec) 중 하나를 주세요: ${String(flags.only)}\n\n`);
582
582
  return EXIT.usage;
583
583
  }
584
+ const { audit } = await import('../src/pack/selfpack.js');
585
+ const { sbom, 심사명세, 명세요약 } = await import('../src/pack/sbom.js');
584
586
  const a = audit();
585
587
  const at = new Date();
586
588
  const 낼것 = 어느것 === 'sbom' ? sbom(a, { at })
@@ -651,6 +653,8 @@ const 값깃발 = new Set([
651
653
  'out', 'only', 'days', 'tool', 'url', 'key', 'model', 'host', 'ports', 'timeout', 'rm', 'delete',
652
654
  // run --check <명령> (agent/donecheck.js) · eval --repeat <수> (src/eval/run.js).
653
655
  'check', 'repeat',
656
+ // run --check-rounds <수> · --check-timeout <초> — 설정의 checkRounds · checkTimeout 을 이 실행에서만 덮는다 (2.1.2).
657
+ 'check-rounds', 'check-timeout',
654
658
  ]);
655
659
  // 값을 줘도 되고 안 줘도 되는 깃발. `--resume` 만 치면 이어 할 대화를 고른다.
656
660
  const 값골라깃발 = new Set(['resume']);
@@ -909,6 +913,7 @@ function help() {
909
913
  say(` ${c.gray('--yes')} 승인이 필요한 것도 그냥 실행. ${c.yellow('기본은 거부입니다')}`);
910
914
  // 새로 넣는 도움말 줄은 말() 로 적는다 — 영어로 켠 사람에게 한국어 줄을 더 새게 하지 않는다(test/langleak.test.js 의 래칫).
911
915
  say(` ${c.gray('--check <cmd>')} ${말('cli.checkFlag')}`);
916
+ say(` ${c.gray('--check-rounds <n> · --check-timeout <s>')} ${말('cli.checkRoundsFlag')}`);
912
917
  /*
913
918
  * 답의 모양을 못 박는 자리 (src/agent/outschema.js).
914
919
  *
@@ -952,6 +957,19 @@ async function main() {
952
957
  읽은인자 = 읽음;
953
958
  const { cmd: 친명령, args, flags, 탈: 인자탈들 } = 읽음;
954
959
 
960
+ /*
961
+ * 판 번호.
962
+ *
963
+ * 연결이 없어도 답해야 한다. `deel --version` 은 "이게 깔려 있나, 무슨 판인가"
964
+ * 를 묻는 것이지 일을 시키는 것이 아닌데, 전에는 설정이 없다는 말이 먼저 나와서
965
+ * 깔린 것 자체가 아닌 줄 알았다. 사내에 반입한 판을 확인할 때 제일 먼저 치는
966
+ * 명령이기도 하다.
967
+ *
968
+ * 그리고 **설정을 읽기 전에** 답한다 (2.1.2). 설정 읽기는 평문 열쇠를 잠그러 옮기며
969
+ * PowerShell 을 띄우고 설정 파일을 고쳐 쓸 수 있다 — 「무슨 판인가」 를 물은 한 줄이 할 일이 아니다.
970
+ */
971
+ if (flags.version || 친명령 === 'version') { say(판번호()); return 0; }
972
+
955
973
  // 서류를 뽑는 명령(audit·sbom·pack)이 어느 말로 나갈지를 여기서 정한다.
956
974
  말정하기();
957
975
 
@@ -966,16 +984,6 @@ async function main() {
966
984
  ? (flags[친명령] = true, '')
967
985
  : 친명령;
968
986
 
969
- /*
970
- * 판 번호.
971
- *
972
- * 연결이 없어도 답해야 한다. `deel --version` 은 "이게 깔려 있나, 무슨 판인가"
973
- * 를 묻는 것이지 일을 시키는 것이 아닌데, 전에는 설정이 없다는 말이 먼저 나와서
974
- * 깔린 것 자체가 아닌 줄 알았다. 사내에 반입한 판을 확인할 때 제일 먼저 치는
975
- * 명령이기도 하다.
976
- */
977
- if (flags.version || cmd === 'version') { say(판번호()); return 0; }
978
-
979
987
  if (flags.help || cmd === 'help') { help(); return 0; }
980
988
 
981
989
  /*
@@ -1091,6 +1099,9 @@ async function main() {
1091
1099
  * 거절한다. 같은 값을 문에 따라 다르게 받으면, 깃발로 준 사람만 제 값이 먹는 줄 안다.
1092
1100
  * `auto` 는 「서버에 맞춤」 이라는 뜻이라 안 준 것으로 받는다.
1093
1101
  */
1102
+ const parseSize = flags.ctx !== undefined || flags['max-tokens'] !== undefined
1103
+ ? (await import('../src/backend/ctxsize.js')).parseSize
1104
+ : () => null;
1094
1105
  for (const 이름 of ['ctx', 'max-tokens']) {
1095
1106
  if (flags[이름] === undefined) continue;
1096
1107
  if (String(flags[이름]).trim().toLowerCase() === 'auto') { delete flags[이름]; continue; }
@@ -1109,7 +1120,7 @@ async function main() {
1109
1120
  // deel -p "..." 처럼 치면 그대로 여기로 온다.
1110
1121
  case 'run':
1111
1122
  case '-p':
1112
- return runOnce({
1123
+ return (await import('../src/oneshot.js')).runOnce({
1113
1124
  prompt: args.join(' '),
1114
1125
  root: flags.root ? String(flags.root) : undefined,
1115
1126
  mode: flags.mode ? String(flags.mode) : undefined,
@@ -1128,10 +1139,12 @@ async function main() {
1128
1139
  outputSchema: flags['output-schema'] ? String(flags['output-schema']) : undefined,
1129
1140
  // 끝내려는 자리에서 돌릴 검사 (agent/donecheck.js). 설정의 check 를 이긴다.
1130
1141
  check: flags.check !== undefined ? String(flags.check) : undefined,
1142
+ checkRounds: flags['check-rounds'] !== undefined ? String(flags['check-rounds']) : undefined,
1143
+ checkTimeout: flags['check-timeout'] !== undefined ? String(flags['check-timeout']) : undefined,
1131
1144
  });
1132
1145
  case '':
1133
1146
  case 'chat':
1134
- return chatLoop({
1147
+ return (await import('../src/repl.js')).chatLoop({
1135
1148
  root: flags.root ? String(flags.root) : undefined,
1136
1149
  mode: flags.mode ? String(flags.mode) : undefined,
1137
1150
  work: flags.work ? String(flags.work) : undefined,
@@ -1160,7 +1173,7 @@ async function main() {
1160
1173
  * 왜 아무 반응이 없는지는 표준오류에 적어 둔다.
1161
1174
  */
1162
1175
  case 'acp':
1163
- return acp({
1176
+ return (await import('../src/acp/serve.js')).acp({
1164
1177
  root: flags.root ? String(flags.root) : undefined,
1165
1178
  mode: flags.mode ? String(flags.mode) : undefined,
1166
1179
  work: flags.work ? String(flags.work) : undefined,
@@ -1173,11 +1186,11 @@ async function main() {
1173
1186
  online: flags.online === true || flags.online === 'true',
1174
1187
  });
1175
1188
  case 'status':
1176
- return showStatus();
1189
+ return (await import('../src/setup.js')).showStatus();
1177
1190
  case 'setup':
1178
- return runSetup(flags);
1191
+ return (await import('../src/setup.js')).runSetup(flags);
1179
1192
  case 'diagnose':
1180
- return runDiagnose(flags);
1193
+ return (await import('../src/setup.js')).runDiagnose(flags);
1181
1194
  /*
1182
1195
  * doctor 는 diagnose 의 **앞자락**이다.
1183
1196
  *
@@ -1203,14 +1216,14 @@ async function main() {
1203
1216
  return runDoc2md(args, flags);
1204
1217
  // 과제 모음(골든셋)을 실제 모델로 돌려 성공률을 잰다 (src/eval/run.js).
1205
1218
  case 'eval':
1206
- return runEval({
1219
+ return (await import('../src/eval/run.js')).runEval({
1207
1220
  폴더: args[0] ?? null,
1208
1221
  init: flags.init === true || flags.init === 'true',
1209
1222
  repeat: flags.repeat ?? 1,
1210
1223
  keep: flags.keep === true || flags.keep === 'true',
1211
1224
  json: flags.json === true || flags.json === 'true',
1212
1225
  only: flags.only !== undefined ? String(flags.only) : null,
1213
- 시간초: flags.timeout !== undefined ? Number(flags.timeout) : null,
1226
+ 시간초: flags.timeout !== undefined ? flags.timeout : null,
1214
1227
  // 과제마다 띄우는 `deel run` 에 그대로 넘길 것 — 바깥 게이트웨이면 --online 이 있어야 돈다.
1215
1228
  깃발들: [
1216
1229
  ...(flags.online === true || flags.online === 'true' ? ['--online'] : []),
@@ -1222,10 +1235,10 @@ async function main() {
1222
1235
  case 'sbom':
1223
1236
  return runSbom(flags);
1224
1237
  case 'scan':
1225
- return runScan(flags);
1238
+ return (await import('../src/backend/scanui.js')).runScan(flags);
1226
1239
  case 'sessions':
1227
1240
  case 'ls':
1228
- return runSessions(flags);
1241
+ return (await import('../src/agent/sessionui.js')).runSessions(flags);
1229
1242
  /*
1230
1243
  * 초기화.
1231
1244
  *
@@ -1233,7 +1246,7 @@ async function main() {
1233
1246
  * 깨져서인데, 읽고 시작하면 그 자리에서 못 넘어간다.
1234
1247
  */
1235
1248
  case 'reset':
1236
- return runReset(args, flags);
1249
+ return (await import('../src/reset.js')).runReset(args, flags);
1237
1250
  /*
1238
1251
  * 탭 완성 스크립트를 낸다 (src/completion.js).
1239
1252
  *
@@ -1241,7 +1254,7 @@ async function main() {
1241
1254
  * 처럼 바로 파이프에 물릴 수 있어야 한다. 그래서 여기서는 say() 를 안 쓴다.
1242
1255
  */
1243
1256
  case 'completion':
1244
- return runCompletion(args);
1257
+ return (await import('../src/completion.js')).runCompletion(args);
1245
1258
  default:
1246
1259
  say('');
1247
1260
  say(` ${c.red('모르는 명령')} ${c.bold(cmd)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "A coding-agent CLI that runs on local models and private gateways. Zero dependencies, Node 20+.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/acp/serve.js CHANGED
@@ -194,6 +194,29 @@ export async function acp(opts = {}) {
194
194
  * 폴더가 다르면 따로 띄운다. 옆 프로젝트의 도구가 딸려 오면 안 된다.
195
195
  */
196
196
  const mcp캐시 = new Map();
197
+
198
+ /*
199
+ * ── 방은 쌓이기만 했다 (2.1.2) ────────────────────────────────────────────
200
+ *
201
+ * 에디터는 대화마다 session/new 를 부르고, 이 프로세스는 에디터가 켜져 있는 동안 산다. 방을
202
+ * 지우는 자리가 없어 하루 종일 연 대화가 전부 메모리에 남았다 — 대화 전체 · 되돌리기 기록 ·
203
+ * 감사기록 · 그 폴더의 MCP 서버까지.
204
+ *
205
+ * 상한을 넘으면 제일 오래 안 쓴 **쉬는** 방을 내려놓는다(도는 턴·차례를 기다리는 턴이 있으면
206
+ * 안 건드린다). 대화 파일은 폴더에 남아 있으므로 그 이름으로 다시 말이 오면 되살려 잇는다
207
+ * (방되찾기) — 에디터 탭은 닫힌 적이 없으니 「그런 세션이 없습니다」 로 답하면 안 된다. 에디터가
208
+ * 고른 작업 모드와 「앞으로 묻지 않기」 도 같이 들고 있다가 돌려준다. 그 폴더의 마지막 방이면
209
+ * MCP 서버도 닫는다.
210
+ */
211
+ const 최대방 = (() => {
212
+ const n = Number(process.env.DEEL_ACP_MAX_SESSIONS);
213
+ return Number.isInteger(n) && n >= 1 ? n : 16;
214
+ })();
215
+ const 치운방 = new Map(); // 아이디 → { root, work, 늘허락 }
216
+ const 되찾는중 = new Map(); // 아이디 → 되살리는 약속 (겹쳐 온 말이 둘을 짓지 않게)
217
+ // 마지막으로 쓴 차례. 시각(Date.now)으로 적으면 한 밀리초 안에 쓴 두 방이 같은 값이 되어, 오래 안 쓴
218
+ // 방 대신 먼저 연 방이 내려놓였다 — 늘 커지는 번호로 적는다.
219
+ let 쓴셈 = 0;
197
220
  let 클라이언트 = null; // initialize 로 받은 저쪽 소개
198
221
  let 시작했나 = false;
199
222
 
@@ -528,6 +551,8 @@ export async function acp(opts = {}) {
528
551
  const 방 = {
529
552
  id: store.id,
530
553
  root, conn, session, store,
554
+ 캐시열쇠,
555
+ 쓴때: ++쓴셈,
531
556
  턴: null,
532
557
  늘허락: new Set(), // 이 세션에서 "앞으로 묻지 않기" 를 고른 도구들
533
558
  mcp: mcp붙임.서버들,
@@ -604,13 +629,70 @@ export async function acp(opts = {}) {
604
629
  } catch { /* 설정값 그대로 간다 */ }
605
630
  }
606
631
 
632
+ /*
633
+ * 내려놓았던 방이면(방되찾기 · session/load) 에디터가 고른 작업 모드와 「앞으로 묻지 않기」 를
634
+ * 돌려준다. **방들 에 넣기 전에** 채운다 — session/load 로 연 방은 이것을 잃었고(2.1.2 2차 눈),
635
+ * 넣은 뒤에 채우면 그 사이 온 말이 빈 채인 방을 받는다.
636
+ */
637
+ const 치운것 = 치운방.get(방.id);
638
+ if (치운것) {
639
+ if (치운것.work) 방.session.work = 치운것.work;
640
+ for (const 이름 of 치운것.늘허락 ?? []) 방.늘허락.add(이름);
641
+ }
607
642
  방들.set(방.id, 방);
643
+ 치운방.delete(방.id); // 되살아났으면 내려놓은 표는 지운다
644
+ 방솎기(방);
608
645
  return 방;
609
646
  }
610
647
 
648
+ /** 상한을 넘었으면 제일 오래 안 쓴 쉬는 방부터 내려놓는다 (위 최대방 머리말). */
649
+ function 방솎기(새방) {
650
+ while (방들.size > 최대방) {
651
+ let 고른 = null;
652
+ for (const 방 of 방들.values()) {
653
+ if (방 === 새방 || 방.턴 || 방.턴끝) continue;
654
+ if (!고른 || 방.쓴때 < 고른.쓴때) 고른 = 방;
655
+ }
656
+ if (!고른) return; // 다 돌고 있으면 넘긴 채 둔다 — 도는 턴을 끊지 않는다
657
+ 방들.delete(고른.id);
658
+ 치운방.set(고른.id, { root: 고른.root, work: 고른.session.work, 늘허락: 고른.늘허락 });
659
+ 로그(`${고른.id} — 오래 안 쓴 세션을 내려놓았습니다 (열어 두는 세션 ${최대방}개 상한). 다시 말하면 이어서 엽니다.`);
660
+ /*
661
+ * 그 폴더의 MCP 서버는 **쓰는 방이 하나도 없을 때만** 닫는다. 짓는 중인 방(열린자리 에는 있고
662
+ * 방들 에는 아직 없는 것)도 쓰는 방이다 — 붙임 약속을 이미 받아 갔다.
663
+ */
664
+ const 아직씀 = [...열린자리].some(([id, 자리]) => !치운방.has(id) && 자리열쇠(자리) === 고른.캐시열쇠);
665
+ if (!아직씀 && mcp캐시.has(고른.캐시열쇠)) {
666
+ const 붙임 = mcp캐시.get(고른.캐시열쇠);
667
+ mcp캐시.delete(고른.캐시열쇠);
668
+ Promise.resolve(붙임).then((b) => {
669
+ for (const 서버 of b?.서버들 ?? []) { try { 서버.닫기(); } catch { /* 닫다 터져도 방은 내려놓는다 */ } }
670
+ }).catch(() => {});
671
+ }
672
+ }
673
+ }
674
+
611
675
  function 방찾기(id) {
612
676
  const 방 = 방들.get(String(id ?? ''));
613
677
  if (!방) throw 잘못된인자오류(`그런 세션이 없습니다: ${id}`);
678
+ 방.쓴때 = ++쓴셈; // 내려놓을 차례는 연 때가 아니라 마지막으로 쓴 때로 정한다
679
+ return 방;
680
+ }
681
+
682
+ /** 내려놓은 방이면 파일에서 되살린다. 아니면 방찾기 와 같다. */
683
+ async function 방되찾기(id) {
684
+ const 아이디 = String(id ?? '');
685
+ if (방들.has(아이디) || !치운방.has(아이디)) return 방찾기(아이디);
686
+ if (!되찾는중.has(아이디)) {
687
+ const 적힌것 = 치운방.get(아이디);
688
+ 되찾는중.set(아이디, (async () => {
689
+ const 방 = await 방만들기({ cwd: 적힌것.root }, { 아이디 }); // 모드 · 늘허락 은 방짓기 가 돌려준다
690
+ 로그(`${아이디} — 내려놓았던 세션을 다시 열었습니다 (${방.session.work})`);
691
+ return 방;
692
+ })().finally(() => 되찾는중.delete(아이디)));
693
+ }
694
+ const 방 = await 되찾는중.get(아이디);
695
+ 방.쓴때 = ++쓴셈;
614
696
  return 방;
615
697
  }
616
698
 
@@ -903,6 +985,7 @@ export async function acp(opts = {}) {
903
985
  // 완료 검사 (agent/donecheck.js). 에디터에도 무엇을 돌렸고 어떻게 됐는지 적는다.
904
986
  case 'check_start':
905
987
  말하기(`\n\n_(${옮긴말('check.start', { 판: ev.판, 최대: ev.최대, 명령: ev.명령 })})_\n\n`);
988
+ for (const 줄 of ev.줄임 ?? []) 말하기(`_(${옮긴말(줄.틀림 ? 'check.invalid' : 'check.clamped', 줄)})_\n\n`);
906
989
  break;
907
990
 
908
991
  case 'check':
@@ -1230,7 +1313,7 @@ export async function acp(opts = {}) {
1230
1313
  }
1231
1314
 
1232
1315
  case 'session/prompt': {
1233
- const 방 = 방찾기(인자?.sessionId);
1316
+ const 방 = await 방되찾기(인자?.sessionId); // 내려놓은 방이면 파일에서 되살려 잇는다
1234
1317
  return await 한턴(방, 인자?.prompt);
1235
1318
  }
1236
1319
 
@@ -1242,7 +1325,7 @@ export async function acp(opts = {}) {
1242
1325
  }
1243
1326
 
1244
1327
  case 'session/set_mode': {
1245
- const 방 = 방찾기(인자?.sessionId);
1328
+ const 방 = await 방되찾기(인자?.sessionId);
1246
1329
  const 준것 = String(인자?.modeId ?? '');
1247
1330
  const 고른것 = 모드정리(준것);
1248
1331
  /*
@@ -32,7 +32,10 @@
32
32
  // 고친 뒤 실패를 받고 **다시 아무것도 안 바꾼 채** 끝내려 하면 다시 돌리지 않는다 —
33
33
  // 같은 결과가 나올 것을 또 기다리게 하지 않고, 실패로 끝낸다.
34
34
 
35
- /** 한 턴에 몇 번까지 「고치고 → 다시 검사」 를 돌까. 기본과 상한. */
35
+ /**
36
+ * 한 턴에 검사를 **몇 번 돌리나**(checkRounds). 기본과 상한. 실패하면 마지막 판을 빼고 출력을 모델에게
37
+ * 돌려주므로 고칠 기회는 판수 − 1 이다 — 3 이면 처음 검사 뒤 두 번 더 고쳐 본다.
38
+ */
36
39
  export const 기본판수 = 3;
37
40
  export const 최대판수 = 10;
38
41
  /** 검사 한 번의 제한 시간(ms). Bash 도구의 상한(10분)과 같다 — 넘겨 봐야 거기서 잘린다. */
@@ -53,13 +56,33 @@ export function 검사설정(cfg = {}, 덮을명령 = undefined) {
53
56
  if (!명령) return null;
54
57
  /*
55
58
  * 판수는 1 ~ 10 에서만 받는다. 0 이나 음수를 「끈다」 로 읽으면 명령을 적어 둔 사람이
56
- * 왜 안 도는지 모른다. 끄려면 check 를 지운다. 숫자가 아니면 기본값이다.
59
+ * 왜 안 도는지 모른다. 끄려면 check 를 지운다. 못 쓰는 값이면 기본값이다.
60
+ *
61
+ * 수와 수로 된 글("5")만 수로 읽는다. 여태 Number() 를 그대로 써서 true 가 1 이 되어 고칠 기회
62
+ * 없이 한 번에 끝났다. 못 쓰는 값은 **기본값을 썼다고 적는다** (아래 줄임 · 2.1.2).
57
63
  */
58
- const n = Number(cfg?.checkRounds);
64
+ const 수로 = (v) => (typeof v === 'number' || (typeof v === 'string' && v.trim() !== '') ? Number(v) : NaN);
65
+ const 적었나 = (v) => v !== undefined && v !== null;
66
+ const n = 수로(cfg?.checkRounds);
59
67
  const 판수 = Number.isInteger(n) && n >= 1 ? Math.min(n, 최대판수) : 기본판수;
60
- const 초 = Number(cfg?.checkTimeout);
68
+ const 초 = 수로(cfg?.checkTimeout);
61
69
  const 시간 = Number.isFinite(초) && 초 > 0 ? Math.min(기본시간, Math.max(최소시간, Math.round(초 * 1000))) : 기본시간;
62
- return { 명령, 판수, 시간 };
70
+ /*
71
+ * 상한을 넘겨 **줄인 것**은 적어 둔다 — 첫 검사 시작 사건에 실려 화면이 말한다.
72
+ * 느린 통합 검사라 1200초를 적었는데 600초에서 잘려 「시간 초과」 로 판수를 다 쓰면, 사람은
73
+ * 검사가 진짜로 안 되는 줄 안다 (2.1.1 검수).
74
+ */
75
+ const 줄임 = [];
76
+ if (Number.isInteger(n) && n > 최대판수) 줄임.push({ 칸: 'checkRounds', 준값: n, 쓴값: 최대판수 });
77
+ if (Number.isFinite(초) && 초 * 1000 > 기본시간) 줄임.push({ 칸: 'checkTimeout', 준값: 초, 쓴값: 기본시간 / 1000 });
78
+ // 못 쓰는 값 — 상한과 같은 자리로 말하되 「넘었다」 가 아니라 「못 쓴다」 로 (틀림).
79
+ if (적었나(cfg?.checkRounds) && !(Number.isInteger(n) && n >= 1)) {
80
+ 줄임.push({ 칸: 'checkRounds', 준값: JSON.stringify(cfg.checkRounds), 쓴값: 기본판수, 틀림: true });
81
+ }
82
+ if (적었나(cfg?.checkTimeout) && !(Number.isFinite(초) && 초 > 0)) {
83
+ 줄임.push({ 칸: 'checkTimeout', 준값: JSON.stringify(cfg.checkTimeout), 쓴값: 기본시간 / 1000, 틀림: true });
84
+ }
85
+ return { 명령, 판수, 시간, 줄임 };
63
86
  }
64
87
 
65
88
  /**
package/src/agent/loop.js CHANGED
@@ -8,12 +8,14 @@ import { toolSchemas, runTool, TOOLS, 파일현황, 바뀔내용 } from '../tool
8
8
  import { isMutating, 셸이파일에쓰나 } from '../safety/guard.js';
9
9
  import { effortFor, tokensFor, fullCap, wasCut, shiftLevel, 자동강도, 천장고르기, 인사인가 as 인사말인가 } from './effort.js';
10
10
  import { 배울전선, 카드고치기, 카드저장꼴, 전선붙이기 } from '../backend/wire.js';
11
+ import { 읽기만하나 } from '../backend/mcp.js';
11
12
  import { 살린쓰기 } from './salvage.js';
12
13
  import { 배울것, 길이문제인가 } from '../backend/learn.js';
13
14
  import { compact, shouldCompact, shouldFold, foldToolResults, foldImages, 못박을것, 접힌파일열쇠 } from './compact.js';
14
15
  import { 걸음수, 하위걸음수, 요약길이 } from './budget.js';
15
16
  import { Session, estimateTokens } from './session.js';
16
17
  import { 최대깊이, 하위모드, 하위요약 } from '../tools/task.js';
18
+ import { 일감인자 } from '../tools/jobs.js';
17
19
  import { 찾기 as 에이전트찾기, 할일합치기, 도구줄이기 } from './agents.js';
18
20
  import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './models.js';
19
21
  import { allowTemporarily, isOffline } from '../safety/network.js';
@@ -370,7 +372,8 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
370
372
  const 명령 = 완료검사.명령;
371
373
  const 최대 = 완료검사.판수;
372
374
  const args = { command: 명령, description: '완료 검사', timeout: 완료검사.시간 };
373
- yield { type: 'check_start', 명령, 판, 최대 };
375
+ // 상한에 걸려 줄인 설정은 첫 판에만 싣는다 — 판마다 같은 말을 되풀이하면 소음이다.
376
+ yield { type: 'check_start', 명령, 판, 최대, ...(판 === 1 && 완료검사.줄임?.length ? { 줄임: 완료검사.줄임 } : {}) };
374
377
  const 못돌림 = (까닭) => ({ ok: null, 명령, 판, 최대, 까닭 });
375
378
 
376
379
  let 결판 = null;
@@ -532,10 +535,22 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
532
535
  if (!부른것.length) return;
533
536
  // 결과가 어느 role 에 실리는지도 규격마다 다르다. `role === 'tool'` 로
534
537
  // 세면 Anthropic 꼴에서 언제나 0 이라, 지어낸 번호가 전부 겹친다.
535
- const 이미 = session.messages.filter(도구결과인가).length;
538
+ /*
539
+ * 지어낸 번호는 부름마다 새로 짓고, **이미 쓰인 번호는 건너뛴다.** 한 번만 세면 id 없는 부름 여럿이
540
+ * 같은 번호를 받고, 결과 수로만 세면 모델이 준 `call_1` 이나 접기 뒤 남은 옛 번호와 겹쳐 또 400 이다
541
+ * (2.1.1 검수 · 2차 눈). 빈 글 id 도 없는 것으로 친다.
542
+ */
543
+ const 쓴번호 = new Set(session.messages.flatMap((m) => 부른것들(m).map((t) => t.id)).filter(Boolean));
544
+ let 이미 = session.messages.filter(도구결과인가).length;
545
+ const 새번호 = () => {
546
+ let id;
547
+ do { id = `call_${++이미}`; } while (쓴번호.has(id));
548
+ 쓴번호.add(id);
549
+ return id;
550
+ };
536
551
  for (const t of 부른것) {
537
552
  session.push(toolMessage(conn.kind, {
538
- callId: t.id ?? `call_${이미 + 1}`,
553
+ callId: t.id || 새번호(),
539
554
  name: t.name ?? '?',
540
555
  content: 왜,
541
556
  }));
@@ -2036,12 +2051,17 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
2036
2051
  const 정책이묻게함 = 승인바닥().바닥 !== 'auto';
2037
2052
  // Jobs 는 대개 뒤에서 도는 명령의 출력을 **읽는** 호출이다. 끝내기(stop)만 바꾸는 것으로
2038
2053
  // 친다 — 읽을 때마다 물으면 사람은 y 만 치다가 정작 물어야 할 것도 그렇게 넘긴다.
2039
- const 바꾸는것 = (call.name === 'Jobs' ? !!call.args?.stop : 바꾸는도구.includes(call.name))
2040
- || call.name.startsWith('mcp__');
2054
+ // 끄는가는 **도구가 읽는 자**(일감인자)로 가른다. `stop` 칸만 보면 작은 모델이 보내는
2055
+ // `kill` · `끝내기` 가 안 묻고 서버를 끄고, `stop:"false"` 는 `!!` 로 참이 되어 헛물음이 난다.
2056
+ const 남의도구 = call.name.startsWith('mcp__');
2057
+ const 바꾸는것 = (call.name === 'Jobs' ? 일감인자(call.args).끝내기 === true : 바꾸는도구.includes(call.name))
2058
+ || 남의도구;
2059
+ // confirm 은 「되돌릴 수 없는 것만」 묻는다. 남의 프로그램이 한 일은 /undo 가 못 되돌리므로
2060
+ // MCP 도구도 묻는다 — 서버가 읽기만 한다고 적은 것만 빼고 (backend/mcp.js 의 읽기만하나).
2041
2061
  const needsOk = (판정.답 === 'allow' && !정책이묻게함) ? false : session.mode === 'strict'
2042
2062
  ? 바꾸는것
2043
2063
  : session.mode === 'confirm'
2044
- ? (call.name === 'Bash' && isMutating(call.args?.command))
2064
+ ? (call.name === 'Bash' && isMutating(call.args?.command)) || (남의도구 && !읽기만하나(ctx.mcp, call.name))
2045
2065
  : false;
2046
2066
  if (needsOk) {
2047
2067
  /*
@@ -1381,7 +1381,8 @@ export function route(text, { 승인받을수있나 = true } = {}) {
1381
1381
 
1382
1382
  // 1·2등이 붙으면 고르지 않는다. 반반인 것을 억지로 정하면 절반은 틀린다.
1383
1383
  const 둘째 = 순위[1]?.[1] ?? 0;
1384
- if (점수 - 둘째 < 2 && 둘째 >= (문턱[순위[1][0]] ?? 4)) {
1384
+ // 둘째가 없을 수도 있다 — 지금 표로는 늘 둘이 남지만, 모드 표가 바뀌면 여기서 사람 말 한 줄이 죽는다.
1385
+ if (순위[1] && 점수 - 둘째 < 2 && 둘째 >= (문턱[순위[1][0]] ?? 4)) {
1385
1386
  return {
1386
1387
  mode: null, score: 점수, why: `${으뜸} 과 ${순위[1][0]} 가 비슷함`,
1387
1388
  겹침: 겹.겹침, 점수들, 뺀것, 일부러: true,