deel-local-cli 1.8.0 → 1.10.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 (49) hide show
  1. package/README.ko.md +1166 -1182
  2. package/README.md +1223 -1232
  3. package/bin/deel.js +55 -12
  4. package/package.json +3 -3
  5. package/src/acp/serve.js +40 -3
  6. package/src/agent/compact.js +314 -296
  7. package/src/agent/effort.js +27 -7
  8. package/src/agent/evidence.js +2 -0
  9. package/src/agent/filemem.js +141 -0
  10. package/src/agent/grade.js +51 -1
  11. package/src/agent/loop.js +202 -35
  12. package/src/agent/session.js +121 -5
  13. package/src/agent/store.js +186 -9
  14. package/src/agent/threads.js +26 -1
  15. package/src/backend/adapter.js +201 -18
  16. package/src/backend/learn.js +24 -0
  17. package/src/backend/mcp.js +96 -5
  18. package/src/backend/probe.js +131 -61
  19. package/src/backend/quota.js +29 -5
  20. package/src/backend/toolfit.js +325 -0
  21. package/src/commands.js +107 -27
  22. package/src/config.js +47 -2
  23. package/src/i18n/en.js +56 -2
  24. package/src/i18n/index.js +18 -0
  25. package/src/i18n/ja.js +58 -2
  26. package/src/i18n/ko.js +103 -2
  27. package/src/i18n/zh.js +58 -2
  28. package/src/lsp/client.js +49 -5
  29. package/src/oneshot.js +81 -3
  30. package/src/pack/sbom.js +30 -4
  31. package/src/pack/selfpack.js +25 -9
  32. package/src/pack/sheet.en.js +288 -0
  33. package/src/pack/tar.js +65 -2
  34. package/src/plugins/manage.js +46 -9
  35. package/src/repl.js +264 -35
  36. package/src/safety/audit.js +92 -8
  37. package/src/safety/authcmd.js +14 -3
  38. package/src/safety/guard.js +143 -0
  39. package/src/safety/keystore.js +62 -39
  40. package/src/safety/undo.js +24 -6
  41. package/src/tools/index.js +239 -34
  42. package/src/tools/jobs.js +158 -29
  43. package/src/tools/verify.js +33 -4
  44. package/src/tools/webfetch.js +92 -9
  45. package/src/ui/md.js +201 -5
  46. package/src/ui/motion.js +0 -1
  47. package/src/ui/pastechip.js +50 -3
  48. package/src/ui/pick.js +115 -0
  49. package/src/ui/screen.js +23 -3
package/src/tools/jobs.js CHANGED
@@ -26,6 +26,7 @@
26
26
  import { spawn, execFileSync } from 'node:child_process';
27
27
  import { decode as decodeBytes, consoleCodepage } from './encoding.js';
28
28
  import { 셸명령 } from './shell.js';
29
+ import { 열쇠뺀환경 } from '../backend/mcp.js';
29
30
  import { 말, 세말 } from '../i18n/index.js';
30
31
 
31
32
  /* 결과 한 줄을 잇는다 — 빈 조각은 버린다(tools/index.js 의 이어 와 같은 것). */
@@ -88,12 +89,87 @@ export function 띄우기옵션(cwd) {
88
89
  const win = process.platform === 'win32';
89
90
  return {
90
91
  cwd,
92
+ // 열쇠만 빼고 나머지는 그대로 물려준다 (backend/mcp.js 열쇠뺀환경 머리말).
93
+ env: 열쇠뺀환경(),
91
94
  windowsHide: true,
92
95
  detached: !win,
93
96
  stdio: ['ignore', 'pipe', 'pipe'],
94
97
  };
95
98
  }
96
99
 
100
+ /**
101
+ * 윈도우에서 pid 아래 나무를 통째로 끊는다.
102
+ *
103
+ * @returns {boolean} 나무를 **다 훑었나.** false 면 손자가 남아 있을 수 있으니,
104
+ * 부르는 쪽은 뿌리(cmd.exe)를 죽이지 말고 다시 와야 한다.
105
+ *
106
+ * ── 실패 두 가지를 갈라야 한다 ──────────────────────────────────────────
107
+ *
108
+ * · 「그런 프로세스 없다」 — 이미 죽은 것이다. 탈이 아니다.
109
+ * · **taskkill 이 제 상한에 걸려 잘렸다** — 나무를 다 못 훑었다.
110
+ *
111
+ * 뒤엣것을 앞엣것처럼 넘기고 뿌리를 죽여 버리면 손자가 영영 못 찾는 채로
112
+ * 남는다. 뿌리가 죽은 뒤에는 `taskkill /pid` 가 「프로세스를 못 찾았습니다」 로
113
+ * 끝나서 다시 부를 수조차 없다.
114
+ *
115
+ * ── 무엇으로 가르나: err.killed 가 아니다 ───────────────────────────────
116
+ *
117
+ * 처음에는 `!err?.killed` 로 갈랐다. **그게 늘 참이었다.** 비동기 execFile 의
118
+ * 콜백은 상한에 걸리면 err.killed 를 true 로 주는데, execFileSync 는 안 준다.
119
+ * 그래서 「나무를 다 못 훑었다」 가 한 번도 안 나왔고, 뿌리를 살려 두는 분기는
120
+ * 처음부터 닿지 않는 코드였다 — 이 판의 대표 기능이 통째로 헛돈 셈이다.
121
+ *
122
+ * 실제로 재 보고 갈랐다(윈도우 11 · Node 20):
123
+ *
124
+ * 상한에 걸림 killed=undefined signal=SIGTERM code=ETIMEDOUT status=null
125
+ * 없는 pid 를 죽임 killed=undefined signal=null code=undefined status=128
126
+ *
127
+ * 갈리는 자리는 **signal 과 code** 다. 아래 검사(test/jobs.test.js 의
128
+ * '★ taskkill 이 잘린 것과 이미 죽은 것을 가른다')가 이 값을 못박아 둔다.
129
+ */
130
+ export function 나무끊기(pid, 상한 = 1500) {
131
+ if (process.platform !== 'win32' || !pid) return true;
132
+ try {
133
+ execFileSync('taskkill', ['/pid', String(pid), '/t', '/f'],
134
+ { windowsHide: true, stdio: 'ignore', timeout: 상한 });
135
+ return true;
136
+ } catch (err) {
137
+ return !잘렸나(err);
138
+ }
139
+ }
140
+
141
+ /** 이 오류가 「상한에 걸려 잘렸다」 인가. 아니면 그냥 0 이 아닌 값으로 끝난 것이다. */
142
+ export function 잘렸나(err) {
143
+ return err?.code === 'ETIMEDOUT' || err?.signal != null || err?.killed === true;
144
+ }
145
+
146
+ /**
147
+ * 유닉스에서 pid 가 이끄는 **무리째** 죽인다.
148
+ *
149
+ * 음수 pid 는 '그 무리 전부' 라는 뜻이다 — 띄울 때 detached 로 무리를 만들어
150
+ * 둔 것과 짝이다. 이걸 안 하고 kid.kill() 만 하면 sh 나 npm 만 죽고 그 아래
151
+ * vite 가 포트를 문 채로 남는다. 무엇이 물고 있는지 못 찾는, 제일 나쁜 자리다.
152
+ *
153
+ * 먼저 곱게 말하고(TERM), 안 들으면 끊는다(KILL). 곧장 KILL 로 가면 dev 서버가
154
+ * 임시 파일이나 소켓을 치울 틈이 없다.
155
+ *
156
+ * **띄울 때 무리를 안 만들었으면 여기서 할 수 있는 것이 없다.** 나중에 무리를
157
+ * 만들어 줄 방법은 없다 — 그래서 이 판단이 띄우는 자리와 짝을 이뤄야 한다.
158
+ * (Bash 도구가 한동안 이 짝을 안 맞춰서, 윈도우만 고쳐지고 유닉스는 그대로였다.)
159
+ */
160
+ export function 무리끊기(pid, { 곧장 = false, 늦게 = 800 } = {}) {
161
+ if (process.platform === 'win32' || !pid) return;
162
+ if (곧장) {
163
+ try { process.kill(-pid, 'SIGKILL'); } catch { /* 이미 죽음 */ }
164
+ return;
165
+ }
166
+ try { process.kill(-pid, 'SIGTERM'); } catch { /* 무리가 없거나 이미 죽음 */ }
167
+ try {
168
+ const 시계 = setTimeout(() => { try { process.kill(-pid, 'SIGKILL'); } catch { /* 죽었다 */ } }, 늦게);
169
+ 시계.unref?.();
170
+ } catch { /* 끝나는 중이면 타이머를 못 건다 — 부르는 쪽이 곧장 으로 다시 온다 */ }
171
+ }
172
+
97
173
  /**
98
174
  * 프로세스 나무를 통째로 끝낸다. 자식만 죽이면 손자가 남아 포트를 계속 문다.
99
175
  *
@@ -110,42 +186,31 @@ export function 띄우기옵션(cwd) {
110
186
  * 그래서 동기로 부른다. 여기서 멈추는 몇십 ms 로 '안 죽은 서버' 를 없앤다.
111
187
  * test/jobs.test.js 의 '프로세스가 진짜 멈춘다' 가 이 자리를 지킨다.
112
188
  */
113
- function 나무죽이기(kid, { 파이프끊기: 끊을까 = true, 곧장 = false } = {}) {
114
- if (!kid) return;
189
+ function 나무죽이기(kid, { 파이프끊기: 끊을까 = true, 곧장 = false, 상한 = 1500, 뿌리살리기 = true } = {}) {
190
+ if (!kid) return true;
191
+ let 나무끊었나 = true;
115
192
  if (process.platform === 'win32' && kid.pid) {
116
193
  /*
117
- * 상한을 짧게 잡는다. 여기는 끝날 때도 지나가는 자리라(process 'exit'),
118
- * 일감마다 몇 초씩 기다리면 deel 이 멈춘 것처럼 보인다. taskkill 은
119
- * 실제로는 수십 ms 돌아온다 값은 그놈이 엉겼을 때의 울타리다.
194
+ * 상한은 부르는 쪽이 정한다. 여기는 끝날 때도 지나가는 자리라
195
+ * (process 'exit'), 일감마다 몇 초씩 기다리면 deel 이 멈춘 것처럼 보인다.
196
+ * 그래서 기본은 짧다. 사람이 「끝내」 라고 시킨 자리에서는 길게 준다
197
+ * 거기서는 몇백 ms 늦는 것보다 안 죽는 것이 훨씬 나쁘다.
120
198
  */
121
- try {
122
- execFileSync('taskkill', ['/pid', String(kid.pid), '/t', '/f'],
123
- { windowsHide: true, stdio: 'ignore', timeout: 1500 });
124
- } catch { /* 이미 죽었으면 0 이 아닌 값으로 끝난다 — 그건 탈이 아니다 */ }
125
- } else if (kid.pid) {
199
+ 나무끊었나 = 나무끊기(kid.pid, 상한);
126
200
  /*
127
- * 유닉스는 **무리째** 죽인다.
201
+ * 나무를 못 훑었으면 뿌리를 살려 둔다. 부르는 쪽이 다시 온다.
128
202
  *
129
- * 음수 pid '그 무리 전부' 라는 뜻이다(띄우기옵션 detached 짝).
130
- * 이걸 안 하고 kid.kill() 하면 sh npm 죽고 그 아래 vite 가
131
- * 포트를 문 채로 남는다 — 무엇이 물고 있는지 못 찾는, 제일 나쁜 자리다.
132
- *
133
- * 먼저 곱게 말하고(TERM), 안 들으면 끊는다(KILL). 곧장 KILL 로 가면
134
- * dev 서버가 임시 파일이나 소켓을 치울 틈이 없다.
203
+ * 자리만 예외다 이상 다시 없는 자리(프로그램이 끝나는
204
+ * )에서는 뿌리라도 죽인다. 살려 두는 것보다는 낫다.
135
205
  */
136
- if (곧장) {
137
- try { process.kill(-kid.pid, 'SIGKILL'); } catch { /* 이미 죽음 */ }
138
- } else {
139
- try { process.kill(-kid.pid, 'SIGTERM'); } catch { /* 무리가 없거나 이미 죽음 */ }
140
- try {
141
- const 시계 = setTimeout(() => { try { process.kill(-kid.pid, 'SIGKILL'); } catch { /* 죽었다 */ } }, 800);
142
- 시계.unref?.();
143
- } catch { /* 끝나는 중이면 타이머를 못 건다 — 부르는 쪽이 곧장 으로 다시 온다 */ }
144
- }
206
+ if (!나무끊었나 && 뿌리살리기) return false;
207
+ } else if (kid.pid) {
208
+ 무리끊기(kid.pid, { 곧장 });
145
209
  }
146
210
  try { kid.kill(); } catch { /* 이미 죽음 */ }
147
211
  if (끊을까) 파이프끊기(kid);
148
212
  try { kid.unref(); } catch { /* 없으면 그만 */ }
213
+ return 나무끊었나;
149
214
  }
150
215
 
151
216
  /**
@@ -439,7 +504,15 @@ export async function 끝내기(번호) {
439
504
  if (!j) return null;
440
505
  if (j.상태 !== '도는중') { 일감들.delete(j.번호); return { 이미: true, 명령: j.명령 }; }
441
506
  j.상태 = '죽임';
442
- 나무죽이기(j.kid, { 파이프끊기: false });
507
+ /*
508
+ * 사람이 시킨 자리라 넉넉히 기다린다.
509
+ *
510
+ * 검사를 통째로 돌릴 때처럼 컴퓨터가 바쁘면 taskkill 이 1.5초를 넘긴다.
511
+ * 그때 잘린 채로 넘어가면 손자가 남는데, 몇백 ms 빨리 돌아오는 것보다
512
+ * 안 죽은 서버 하나가 훨씬 비싸다.
513
+ */
514
+ const 넉넉히 = 6000;
515
+ 나무죽이기(j.kid, { 파이프끊기: false, 상한: 넉넉히 });
443
516
  await 죽는말기다리기(j.kid, 죽는말기다림);
444
517
  /*
445
518
  * 아직 살아 있으면 여기서 끊는다.
@@ -447,10 +520,44 @@ export async function 끝내기(번호) {
447
520
  * 곧 목록에서 지울 참인데, 지우고 나면 이놈을 가리킬 방법이 없다. 안 죽은
448
521
  * 채로 지우면 영영 못 찾는 프로세스가 하나 남는다 — 이 기능이 없애려던
449
522
  * 바로 그 상태다. 유닉스에서 SIGTERM 을 안 듣는 놈이 여기로 온다.
523
+ * 윈도우에서는 나무를 다 못 훑어 뿌리를 살려 둔 경우가 여기로 온다.
524
+ *
525
+ * **뿌리살리기를 끈다.** 이걸 안 끄면 윈도우에서 taskkill 이 또 잘렸을 때
526
+ * 나무죽이기 가 kid.kill() 에 닿지도 못하고 그냥 돌아온다 — 「곧장」 이
527
+ * 윈도우에서는 아무 뜻이 없었다는 말이다. 여기는 마지막 자리라 뿌리라도
528
+ * 죽여야 한다. (모두끝내기 는 이미 그렇게 하고 있었다.)
450
529
  */
451
- if (아직사나(j.kid)) 나무죽이기(j.kid, { 파이프끊기: false, 곧장: true });
530
+ if (아직사나(j.kid)) {
531
+ 나무죽이기(j.kid, { 파이프끊기: false, 곧장: true, 상한: 넉넉히, 뿌리살리기: false });
532
+ /*
533
+ * 죽인 **직후에 살았나 물으면 안 된다.**
534
+ *
535
+ * 아직사나() 는 exitCode·signalCode 를 보는데, 그 값은 Node 가 종료를
536
+ * 실제로 **관찰한 뒤에** 채워진다. 동기 taskkill 이 돌아온 순간에는 아직
537
+ * 비어 있을 수 있다 — 그러면 멀쩡히 죽은 것을 「안 죽었다」 로 읽는다.
538
+ *
539
+ * 검사를 통째로 돌릴 때 이걸로 한 번 헛짚었다. 혼자 돌리면 초록이고
540
+ * 전체에서만 빨개졌다 — 컴퓨터가 바쁘면 관찰이 늦기 때문이다.
541
+ * 그러니 한 번 더 기다렸다가 묻는다.
542
+ */
543
+ await 죽는말기다리기(j.kid, 죽는말기다림);
544
+ }
452
545
  파이프끊기(j.kid);
453
546
  const 남은 = j.읽기({});
547
+ /*
548
+ * ★ 그러고도 안 죽었으면 **그렇다고 말한다.**
549
+ *
550
+ * 전에는 성패를 안 보고 늘 성공을 돌려줬다. 그러면 모델과 사람은 「끝냈습니다」
551
+ * 를 받는데 서버는 살아서 포트를 물고 있고, 목록에서도 지워져 다시 부를
552
+ * 방법이 없다. 이 파일 머리말이 스스로 적어 둔 「안 죽은 채로 지우면 영영
553
+ * 못 찾는 프로세스가 하나 남는다」 가 그대로 성립하고 있었다.
554
+ *
555
+ * 안 죽었으면 목록에 남긴다. 그래야 사람이 한 번 더 시킬 수 있다.
556
+ */
557
+ if (아직사나(j.kid)) {
558
+ j.상태 = '도는중'; // 지우지 않는다 — 다시 시킬 수 있어야 한다
559
+ return { 이미: false, 안죽음: true, 명령: j.명령, 초: j.산햇수(), 남은: 남은.글 };
560
+ }
454
561
  일감들.delete(j.번호);
455
562
  return { 이미: false, 명령: j.명령, 초: j.산햇수(), 남은: 남은.글 };
456
563
  }
@@ -464,7 +571,14 @@ export async function 끝내기(번호) {
464
571
  export function 모두끝내기() {
465
572
  const n = [...일감들.values()].filter((j) => j.상태 === '도는중').length;
466
573
  for (const j of 일감들.values()) {
467
- if (j.상태 === '도는중') { j.상태 = '죽임'; 나무죽이기(j.kid); }
574
+ if (j.상태 !== '도는중') continue;
575
+ j.상태 = '죽임';
576
+ /*
577
+ * 여기는 프로그램이 끝나는 길이라 **다시 올 기회가 없다.** 한 번 짧게
578
+ * 훑어 보고, 잘렸으면 한 번 더 길게 훑는다. 그러고도 안 되면 뿌리라도
579
+ * 죽인다 — 아무것도 안 죽이는 것보다 낫다.
580
+ */
581
+ if (!나무죽이기(j.kid)) 나무죽이기(j.kid, { 상한: 4000, 뿌리살리기: false });
468
582
  }
469
583
  일감들.clear();
470
584
  return n;
@@ -637,6 +751,21 @@ export const JOBS_TOOL = {
637
751
  const r = await 끝내기(번호);
638
752
  if (!r) return { error: `${번호}번 일감이 없습니다. 번호 없이 Jobs 를 불러 목록을 보세요.` };
639
753
  if (r.이미) return { content: `${번호}번은 이미 끝나 있었습니다: ${r.명령}`, summary: 말('sum.jobEndedAlready') };
754
+ /*
755
+ * ★ 안 죽었으면 「끝냈습니다」 라고 하지 않는다.
756
+ *
757
+ * 여기서 거짓말하면 모델은 다음 단계로 가고 사람은 포트를 문 채로 남은
758
+ * 서버를 못 찾는다 — 이 기능이 없애려던 바로 그 상태다. 목록에도
759
+ * 남겨 뒀으니 한 번 더 시킬 수 있다고 알려 준다.
760
+ */
761
+ if (r.안죽음) {
762
+ return {
763
+ error: `${번호}번이 안 끝났습니다 (${r.초}초 돌았습니다): ${r.명령}`
764
+ + '\n죽이라고 시켰는데 아직 살아 있습니다. 목록에 그대로 두었으니 한 번 더 시켜 보세요.'
765
+ + (r.남은 ? `\n\n마지막 출력:\n${뒤만(r.남은, 2000).글}` : ''),
766
+ summary: 말('sum.jobNotStopped'),
767
+ };
768
+ }
640
769
  return {
641
770
  content: `${번호}번을 끝냈습니다 (${r.초}초 돌았습니다): ${r.명령}`
642
771
  + (r.남은 ? `\n\n마지막 출력:\n${뒤만(r.남은, 2000).글}` : ''),
@@ -122,6 +122,15 @@ function 명령돌리기(cmd, cwd, 제한 = 60000) {
122
122
  });
123
123
  }
124
124
 
125
+ /**
126
+ * 폴더를 통째로 볼 때 한 번에 보는 파일 수.
127
+ *
128
+ * 상한이 있는 것 자체는 옳다 — 저장소가 크면 한 번의 Verify 가 몇 분이 된다.
129
+ * 다만 **넘친 것을 말해야** 한다. 안 말하면 마흔한 번째 파일이 깨져 있어도
130
+ * 「확인했습니다」 로 끝난다. 이 파일이 있는 까닭과 정면으로 어긋난다.
131
+ */
132
+ const 한번에최대 = 40;
133
+
125
134
  /** 이 확장자를 돌려 볼 명령이 있나. 없으면 null — 그러면 '못 돌려 봤다' 고 말한다. */
126
135
  function 돌릴명령(확장) {
127
136
  switch (확장) {
@@ -165,6 +174,8 @@ export const VERIFY_TOOL = {
165
174
 
166
175
  // 볼 파일 고르기. 안 주면 작업 폴더에서 확인할 수 있는 것을 찾는다.
167
176
  let 볼것 = [];
177
+ // 상한에 걸려 안 본 것의 수. 0 이 아니면 아래에서 못한것 에 넣는다.
178
+ let 넘친수 = 0;
168
179
  // .gitignore 로 안 본 것은 셈해 뒀다가 끝에 적는다. 조용히 빼면
169
180
  // "빌드 산출물에 탈이 있는데 왜 확인이 통과냐" 를 사람이 못 푼다.
170
181
  const 건너뜀 = { 폴더: 0, 파일: 0 };
@@ -190,15 +201,33 @@ export const VERIFY_TOOL = {
190
201
  } else {
191
202
  const 전부 = walk(뿌리, { skipDirs: SKIP_DIRS });
192
203
  셈더하기(전부);
193
- 볼것 = 전부
194
- .filter((f) => ['.html', '.htm', '.css', '.json', '.js', '.mjs', '.cjs', '.py'].includes(extname(f.path).toLowerCase()))
195
- .slice(0, 40)
196
- .map((f) => ({ path: f.path }));
204
+ const 볼만한것 = 전부
205
+ .filter((f) => ['.html', '.htm', '.css', '.json', '.js', '.mjs', '.cjs', '.py'].includes(extname(f.path).toLowerCase()));
206
+ 볼것 = 볼만한것.slice(0, 한번에최대).map((f) => ({ path: f.path }));
207
+ /*
208
+ * 상한에 걸려 안 본 것은 **말한다.**
209
+ *
210
+ * 이 파일 머리말이 스스로 못 박아 둔 것이 있다 — 「못 확인한 것은 못
211
+ * 확인했다고 말한다. 확인 못 한 것을 확인했다고 하는 것이 제일 나쁘다.」
212
+ * .gitignore 로 건너뛴 것과 walk 상한은 그렇게 하고 있었는데, **이
213
+ * 상한만 아무 데도 안 적혔다.**
214
+ *
215
+ * 그래서 파일이 마흔 개를 넘으면, 마흔한 번째부터는 깨져 있어도
216
+ * 「확인했습니다」 로 끝났다. failed 도 거짓이라 루프까지 성공으로
217
+ * 넘어간다. 상한 자체보다 **말을 안 한 것**이 탈이다.
218
+ */
219
+ 넘친수 = 볼만한것.length - 볼것.length;
197
220
  }
198
221
 
199
222
  const 된것 = [];
200
223
  const 탈난것 = [];
201
224
  const 못한것 = [];
225
+ if (넘친수 > 0) {
226
+ 못한것.push({
227
+ 이름: `그 밖의 ${넘친수}개`,
228
+ 왜: `한 번에 ${한번에최대}개까지만 봅니다 — 나머지는 paths 로 짚어서 다시 부르세요`,
229
+ });
230
+ }
202
231
 
203
232
  for (const x of 볼것) {
204
233
  if (x.없음) { 탈난것.push({ 이름: x.없음, 탈: [x.왜] }); continue; }
@@ -56,17 +56,43 @@ const MAX_BYTES = 2 * 1024 * 1024; // 2MB 넘게 받지 않는다
56
56
  const 집줄 = new Map(); // origin → 그 집의 마지막 차례가 끝나는 약속
57
57
  const 집간격 = 400; // 같은 집을 다시 두드리기 전에 쉬는 시간
58
58
 
59
- const 잠깐 = (ms) => new Promise((r) => setTimeout(r, ms));
59
+ /**
60
+ * 잠깐 잔다. 멈추라고 하면 자다가도 일어난다.
61
+ *
62
+ * 이 파일에는 사람을 최대 10초까지 붙잡는 잠이 두 군데 있다 — 줄 서는 사이와
63
+ * 429 뒤에 쉬는 사이다. 그냥 setTimeout 이면 ESC 를 눌러도 그 10초는 그대로
64
+ * 흐른다. 화면에는 「멈추는 중…」 이 10초 내내 떠 있고, 사람 눈에는 ESC 가
65
+ * 안 먹은 것과 똑같이 보인다 — 제보받은 그 증상이다.
66
+ */
67
+ const 잠깐 = (ms, signal = null) => new Promise((풀기) => {
68
+ const t = setTimeout(풀기, ms);
69
+ signal?.addEventListener?.('abort', () => { clearTimeout(t); 풀기(); }, { once: true });
70
+ });
60
71
 
61
- function 한집씩(origin, ) {
72
+ function 한집씩(origin, 일, signal = null) {
62
73
  const 앞사람 = 집줄.get(origin);
63
74
  const 내차례 = (앞사람 ?? Promise.resolve())
64
- .then(async () => { if (앞사람) await 잠깐(집간격); return 일(); });
75
+ .then(async () => {
76
+ if (앞사람) await 잠깐(집간격, signal);
77
+ // 줄을 서 있는 사이에 멈췄으면 두드리지 않는다. 안 두드려도 잃을 것이 없다 —
78
+ // 아직 나간 것이 하나도 없는 자리다.
79
+ if (signal?.aborted) throw new Error('중단했습니다');
80
+ return 일();
81
+ });
65
82
  // 다음 사람이 기다리는 것은 '내가 끝났다' 뿐이다. 내가 실패해도 줄은 넘어간다.
66
83
  집줄.set(origin, 내차례.then(() => {}, () => {}));
67
84
  return 내차례;
68
85
  }
69
86
 
87
+ /**
88
+ * 멈췄을 때 돌려줄 모양.
89
+ *
90
+ * 다른 도구들과 **같은 모양**이어야 한다(tools/index.js 의 runTool). 멈춤은
91
+ * 실패가 아니라서 중단됨 을 따로 단다 — 실패로 세면 되풀이 감지가 엉뚱하게
92
+ * 걸려서 다음에 같은 주소를 부르는 것까지 막힌다.
93
+ */
94
+ const 중단결과 = () => ({ error: '중단했습니다. 웹을 읽다 말았습니다.', 끝났다: true, 중단됨: true });
95
+
70
96
  /*
71
97
  * 잠시 뒤에 다시 하면 되는 것들.
72
98
  *
@@ -127,8 +153,45 @@ function 태그벗기기(html) {
127
153
  * allowPrivate 는 검사용이다. 도구 스키마에 없으므로 모델은 이 값을 줄 수 없다.
128
154
  * (환경변수로 열어 두면 실제 사용 중에도 열려 버린다 — 그래서 인자로만 둔다)
129
155
  */
130
- export async function webFetch(args, { allowPrivate = false, 모델컨텍스트 = null } = {}) {
156
+ /**
157
+ * 되돌림(redirect)을 따라가도 되는 곳인가. 던지면 안 따라간다.
158
+ *
159
+ * ── 왜 이름을 붙여 꺼냈나 ───────────────────────────────────────────────
160
+ *
161
+ * 이 판단이 webFetch 안의 이름 없는 함수로 묻혀 있었다. 그래서 **어떤 검사도
162
+ * 여기를 안 지났다** — 두 줄을 통째로 지워도 검사가 전부 초록이었다.
163
+ * 직접 친 주소가 사내망인 것은 재고 있었는데, **바깥 주소가 302 로 사내망을
164
+ * 가리키는** 길은 아무도 안 지났다. 위험한 쪽은 이쪽이다.
165
+ *
166
+ * 이름을 붙이면 재 볼 수 있다. `플러그인되돌림`(plugins/manage.js)이 같은
167
+ * 까닭으로 먼저 그렇게 되어 있다.
168
+ *
169
+ * ── 무엇을 막나 ─────────────────────────────────────────────────────────
170
+ *
171
+ * 첫째, http·https 가 아닌 곳. `file:///etc/passwd` 로 되돌리면 남의 서버가
172
+ * 우리 디스크를 읽어 제 화면에 실어 보낼 수 있다.
173
+ * 둘째, 이 컴퓨터·사내망 주소. 바깥에서 시작한 요청이 302 한 번으로 사내망
174
+ * 안쪽에 닿으면, 「소스가 어디로도 안 나간다」 와 짝을 이루는 문장이 깨진다.
175
+ */
176
+ export function 웹되돌림(다음, { allowPrivate = false } = {}) {
177
+ if (다음.protocol !== 'http:' && 다음.protocol !== 'https:') {
178
+ throw new Error(`${다음.protocol} 로 되돌립니다 — 따라가지 않습니다`);
179
+ }
180
+ if (isLocalHost(다음.hostname) && !allowPrivate) {
181
+ throw new Error(`이 컴퓨터·사내망 주소(${다음.hostname})로 되돌립니다 — 따라가지 않습니다`);
182
+ }
183
+ }
184
+
185
+ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트 = null, signal = null } = {}) {
131
186
  const raw = String(args?.url ?? '').trim();
187
+ /*
188
+ * 이미 멈췄으면 아예 안 나간다.
189
+ *
190
+ * 여럿을 함께 부를 때(loop.js 의 Promise.all) 앞엣것이 도는 사이 ESC 를
191
+ * 누르면, 뒤엣것들은 아직 아무 데도 안 두드렸는데 그대로 나갔다. 나가면
192
+ * 상대 서버에는 기록이 남는다 — 멈춘 뒤에 남기는 발자국은 설명할 길이 없다.
193
+ */
194
+ if (signal?.aborted) return 중단결과();
132
195
  /*
133
196
  * 얼마나 가져올지는 **모델에 맞춰** 정한다 (agent/budget.js).
134
197
  *
@@ -164,19 +227,28 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
164
227
  method: 'GET', // 보내는 건 없다
165
228
  headers: { 'User-Agent': 'deel/cli', Accept: 'text/html,text/plain,application/json;q=0.9,*/*;q=0.5' },
166
229
  timeout: 30000,
230
+ /*
231
+ * 사람이 누른 ESC 를 여기까지 데려온다.
232
+ *
233
+ * 여태 이 자리에는 시한(30초)만 있었다. 그래서 안 답하는 서버를 하나
234
+ * 물면 ESC 를 눌러도 30초를 꼬박 기다렸다 — 「ESC 를 눌러도 안 멈춘다」
235
+ * 는 제보의 한 갈래가 정확히 이것이다. http.js 의 신호() 가 시한과
236
+ * 이 신호를 AbortSignal.any 로 묶어 준다.
237
+ */
238
+ signal,
167
239
  stream: true, // 상한까지만 받는다 — 다 받아 놓고 버리지 않는다
168
240
  되돌림: (다음) => {
169
- if (다음.protocol !== 'http:' && 다음.protocol !== 'https:') throw new Error(`${다음.protocol} 로 되돌립니다 — 따라가지 않습니다`);
170
- if (isLocalHost(다음.hostname) && !allowPrivate) throw new Error(`이 컴퓨터·사내망 주소(${다음.hostname})로 되돌립니다 — 따라가지 않습니다`);
241
+ 웹되돌림(다음, { allowPrivate });
171
242
  열어둔.push(allowTemporarily(다음.origin));
172
243
  },
173
- }));
244
+ }), signal);
174
245
  방문기록.push({ url: u.href, status: res.status, at: new Date().toISOString() });
175
246
  if (res.ok || !다시할것.has(res.status) || 회차 >= 다시횟수) break;
176
247
  await res.버리기?.();
177
248
  const 초 = 얼마나쉬라나(res.headers.get('retry-after'), 회차);
178
249
  쉰시간 += 초;
179
- await 잠깐(초 * 1000);
250
+ await 잠깐(초 * 1000, signal);
251
+ if (signal?.aborted) return 중단결과();
180
252
  }
181
253
 
182
254
  if (!res.ok) {
@@ -282,6 +354,15 @@ export async function webFetch(args, { allowPrivate = false, 모델컨텍스트
282
354
  };
283
355
  } catch (err) {
284
356
  const m = String(err?.message ?? err);
357
+ /*
358
+ * 멈춤이 먼저다.
359
+ *
360
+ * 신호가 끊으면 fetch 는 AbortError 를 던지는데, 그 말은 「이 작업이
361
+ * 중단되었습니다」 라는 영어 한 줄이다. 그대로 오류로 올리면 화면에는
362
+ * 사람이 누른 ESC 가 남의 서버 잘못처럼 찍힌다. 시한 초과와도 구별해야
363
+ * 한다 — 시한은 상대가 늦은 것이고, 이건 우리가 그만둔 것이다.
364
+ */
365
+ if (signal?.aborted) return 중단결과();
285
366
  if (err?.name === 'TimeoutError') return { error: '시간 초과 — 응답이 없습니다.' };
286
367
  if (/ENOTFOUND|getaddrinfo/i.test(m)) return { error: '주소를 찾을 수 없습니다 (DNS).' };
287
368
  return { error: m };
@@ -306,5 +387,7 @@ export const WEB_FETCH_TOOL = {
306
387
  required: ['url'],
307
388
  },
308
389
  },
309
- run: (args, ctx) => webFetch(args, { 모델컨텍스트: ctx?.모델컨텍스트 ?? null }),
390
+ // signal 같이 넘긴다. 넘기면 ESC 눌러도 이 도구만 최대 30초를 더 산다
391
+ // — 화면은 「멈추는 중…」 인데 실제로는 남의 서버를 계속 붙들고 있는 상태다.
392
+ run: (args, ctx) => webFetch(args, { 모델컨텍스트: ctx?.모델컨텍스트 ?? null, signal: ctx?.signal ?? null }),
310
393
  };