deel-local-cli 1.0.2 → 1.1.1

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 (37) hide show
  1. package/README.en.md +381 -7
  2. package/README.md +493 -9
  3. package/bin/deel.js +266 -234
  4. package/package.json +4 -3
  5. package/src/agent/budget.js +167 -0
  6. package/src/agent/grade.js +207 -0
  7. package/src/agent/loop.js +778 -574
  8. package/src/agent/modes.js +109 -28
  9. package/src/agent/project.js +171 -0
  10. package/src/agent/route.js +55 -3
  11. package/src/agent/session.js +131 -12
  12. package/src/backend/http.js +41 -4
  13. package/src/backend/learn.js +46 -4
  14. package/src/commands.js +178 -3
  15. package/src/oneshot.js +390 -327
  16. package/src/preview/serve.js +326 -0
  17. package/src/repl.js +1184 -887
  18. package/src/safety/audit.js +3 -1
  19. package/src/skills/builtin//352/262/200/354/202/254-/353/250/274/354/240/200/SKILL.md +64 -0
  20. package/src/skills/builtin//352/271/212/354/235/264/354/236/210/352/262/214-/353/247/214/353/223/244/352/270/260/SKILL.md +78 -0
  21. package/src/skills/builtin//353/201/235/352/271/214/354/247/200-/355/225/230/352/270/260/SKILL.md +65 -0
  22. package/src/skills/builtin//354/212/244/354/212/244/353/241/234-/352/262/200/355/206/240/SKILL.md +74 -0
  23. package/src/skills/builtin//354/260/224/353/237/254/353/263/264/352/270/260/SKILL.md +59 -0
  24. package/src/skills/builtin//354/260/250/352/267/274/354/260/250/352/267/274-/353/224/224/353/262/204/352/271/205/SKILL.md +73 -0
  25. package/src/skills/builtin//354/275/224/353/223/234-/354/244/204/354/235/264/352/270/260/SKILL.md +66 -0
  26. package/src/skills/discover.js +17 -2
  27. package/src/tools/index.js +500 -107
  28. package/src/tools/jobs.js +670 -0
  29. package/src/tools/outline.js +331 -0
  30. package/src/tools/task.js +153 -0
  31. package/src/tools/verify.js +306 -0
  32. package/src/tools/webfetch.js +152 -16
  33. package/src/ui/inputbox.js +102 -7
  34. package/src/ui/motion.js +212 -0
  35. package/src/ui/screen.js +51 -3
  36. package/src/ui/status.js +69 -4
  37. package/src/ui/working.js +8 -1
@@ -0,0 +1,670 @@
1
+ /*
2
+ * 뒤에서 계속 도는 명령.
3
+ *
4
+ * 왜 필요한가:
5
+ * Bash 는 명령이 **끝나야** 결과를 준다. 그래서 끝나지 않는 것을 못 돌린다 —
6
+ * `npm run dev`, `python -m http.server`, `vite`, `npm run watch` 가 전부 그렇다.
7
+ * 전에는 이걸 시키면 120초를 기다렸다가 시간 초과로 죽였다. 화면에는
8
+ * `시간 초과로 중단됨` 만 남는다. 모델은 서버를 못 띄운 것으로 알고 포기하거나,
9
+ * 더 나쁘게는 timeout 을 늘려서 다시 부른다 — 그러면 그 턴이 통째로 멈춘다.
10
+ *
11
+ * 만든 것을 **띄워서 확인**하는 것이 바이브코딩의 마지막 한 칸이다.
12
+ * Verify 가 "문법은 맞다" 까지 봐 주지만, 진짜로 뜨는지는 띄워 봐야 안다.
13
+ *
14
+ * 무엇을 하나:
15
+ * 명령을 띄우고 **바로 돌아온다.** 출력은 여기에 쌓아 두고, 모델이 Jobs 로
16
+ * 읽어 간다. 뜨자마자 죽는 경우(포트가 이미 물려 있다 등)가 흔해서, 띄운 직후
17
+ * 잠깐 기다렸다가 그 사이 나온 것을 같이 준다 — 그 몇 줄이 대부분의 답이다.
18
+ *
19
+ * 지키는 선:
20
+ * · 안전 검사는 Bash 와 **똑같이** 거친다. 여기가 뒷문이 되면 안 된다.
21
+ * (checkCommand·checkPaths 는 부르는 쪽에서 이미 거친다 — index.js 참고)
22
+ * · deel 이 끝나면 전부 죽인다. 남겨 두면 사람이 모르는 프로세스가 계속 돈다.
23
+ * 그건 '내가 안 띄운 서버가 포트를 물고 있는' 상태고, 원인을 못 찾는다.
24
+ * · 출력은 상한이 있다. watch 하나가 몇 시간 돌면 몇 GB 가 된다.
25
+ */
26
+ import { spawn, execFileSync } from 'node:child_process';
27
+ import { decode as decodeBytes, consoleCodepage } from './encoding.js';
28
+
29
+ // 한 일감이 **들고 있을** 출력 상한. 넘으면 앞을 버리고 뒤를 남긴다 —
30
+ // 오래 도는 것에서 필요한 건 언제나 **방금** 나온 쪽이다.
31
+ const 출력상한 = 256 * 1024;
32
+ /*
33
+ * 한 번에 **모델에게 건넬** 상한. 위엣것과 다른 값이어야 한다.
34
+ *
35
+ * 둘을 같은 값으로 두면 watch 하나가 넘칠 때마다 Jobs 읽기 한 번이 256KB 를
36
+ * 창에 쏟는다. 8k 모델이면 그 한 번으로 창이 끝나고, 큰 창에서도 대화가
37
+ * 통째로 밀려 나간다. 게다가 넘친 직후에는 커서가 0 으로 돌아가 있어서
38
+ * (담기 참고) '새것만' 달라고 해도 통째로 온다 — 그러니 여기가 막혀 있어야 한다.
39
+ *
40
+ * Bash 의 background 쪽은 처음부터 4,000자로 줄이고 있었는데 이 길만 뚫려 있었다.
41
+ */
42
+ const 건넬상한 = 4000;
43
+ // 동시에 띄워 둘 수 있는 개수. 이보다 많아지면 사람이 못 따라간다.
44
+ export const 최대일감 = 8;
45
+ /*
46
+ * **끝난** 일감을 몇 개까지 들고 있나.
47
+ *
48
+ * 바로 지우면 안 된다 — 마지막 출력을 읽으라고 남겨 두는 것이다. 그런데
49
+ * 안 지우면 짧은 명령 서른 개에 항목 서른 개가 쌓이고, 하나가 최대 256KB 를
50
+ * 들고 있으니 몇 MB 가 된다. 목록을 볼 때마다 그걸 전부 다시 푼다.
51
+ *
52
+ * 그래서 최근 것만 남기고 오래된 것부터 버린다. 도는 것은 여기 안 센다 —
53
+ * 그건 최대일감 이 따로 막는다.
54
+ */
55
+ const 끝난것상한 = 8;
56
+ /*
57
+ * 죽인 뒤 남은 말을 얼마나 기다리나.
58
+ *
59
+ * 유닉스 쪽 SIGKILL 올려치기(800ms)보다 짧게 잡는다. 여기서 먼저 깨어나
60
+ * '아직 사나' 를 보고, 살아 있으면 그 자리에서 곧장 끊기 때문이다.
61
+ */
62
+ const 죽는말기다림 = 700;
63
+
64
+ /**
65
+ * 명령을 셸에 넘기는 방법.
66
+ *
67
+ * 윈도우에서 여기가 조용히 틀리면 따옴표가 든 명령이 통째로 뭉개진다 —
68
+ * 출력도 오류도 없이 **종료코드 0** 이다. Bash 쪽과 같은 값을 쓴다.
69
+ * 한 군데서만 정의하는 것이 중요하다. 두 벌이 되면 한쪽만 고쳐진다.
70
+ */
71
+ export function 셸명령(cmd) {
72
+ return process.platform === 'win32'
73
+ ? { file: process.env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', `"${cmd}"`], verbatim: true }
74
+ : { file: '/bin/sh', args: ['-c', cmd], verbatim: false };
75
+ }
76
+
77
+ /**
78
+ * 어떻게 띄우나 — spawn 에 넘길 것들.
79
+ *
80
+ * `detached` 가 이 함수가 있는 이유다. 윈도우는 `taskkill /t` 가 프로세스
81
+ * 나무를 훑어 주지만 유닉스에는 그런 것이 없다. **띄울 때 무리(process group)를
82
+ * 만들어 두지 않으면 나중에 손자를 가리킬 방법이 아예 없다.**
83
+ *
84
+ * `npm run dev` 는 npm → node → vite 로 내려가고, 포트를 무는 것은 맨 아래다.
85
+ * sh 에 SIGTERM 만 보내면 그 아래가 그대로 남아서 — 사람 눈에 안 보이는 채로 —
86
+ * 계속 돈다. 나중에 고칠 수 없는 종류라 띄우는 순간에 정해 둔다.
87
+ *
88
+ * 윈도우에서는 반대로 무리를 안 만든다. detached 는 거기서 새 콘솔 창을
89
+ * 띄우는 쪽으로 작동해서, 조용히 돌아야 할 것이 화면에 튀어나온다.
90
+ */
91
+ export function 띄우기옵션(cwd) {
92
+ const win = process.platform === 'win32';
93
+ return {
94
+ cwd,
95
+ windowsHide: true,
96
+ detached: !win,
97
+ stdio: ['ignore', 'pipe', 'pipe'],
98
+ };
99
+ }
100
+
101
+ /**
102
+ * 프로세스 나무를 통째로 끝낸다. 자식만 죽이면 손자가 남아 포트를 계속 문다.
103
+ *
104
+ * 윈도우에서 taskkill 을 **기다린다.** 전에는 execFile(비동기)로 불렀는데,
105
+ * 그러면 두 가지가 조용히 어긋난다.
106
+ *
107
+ * 1. 바로 아래 kid.kill() 이 먼저 돌아 cmd 를 죽인다. 나무의 뿌리가 없어진
108
+ * 뒤에 taskkill 이 뜨므로 손자 — `npm run dev` 의 진짜 서버 — 를 못 찾고,
109
+ * 그놈은 그대로 남아 포트를 문다. 죽이라고 말은 했는데 안 죽는다.
110
+ * 2. process 의 'exit' 에서 부를 때는 **아예 안 돈다.** 그 자리에서는
111
+ * 비동기 일감이 하나도 실행되지 않는다. 끄려고 걸어 둔 마지막 그물이
112
+ * 정작 제일 흔한 끝맺음(그냥 종료)에서 통째로 비어 있었던 셈이다.
113
+ *
114
+ * 그래서 동기로 부른다. 여기서 멈추는 몇십 ms 로 '안 죽은 서버' 를 없앤다.
115
+ * test/jobs.test.js 의 '프로세스가 진짜 멈춘다' 가 이 자리를 지킨다.
116
+ */
117
+ function 나무죽이기(kid, { 파이프끊기: 끊을까 = true, 곧장 = false } = {}) {
118
+ if (!kid) return;
119
+ if (process.platform === 'win32' && kid.pid) {
120
+ /*
121
+ * 상한을 짧게 잡는다. 여기는 끝날 때도 지나가는 자리라(process 'exit'),
122
+ * 일감마다 몇 초씩 기다리면 deel 이 멈춘 것처럼 보인다. taskkill 은
123
+ * 실제로는 수십 ms 면 돌아온다 — 이 값은 그놈이 엉겼을 때의 울타리다.
124
+ */
125
+ try {
126
+ execFileSync('taskkill', ['/pid', String(kid.pid), '/t', '/f'],
127
+ { windowsHide: true, stdio: 'ignore', timeout: 1500 });
128
+ } catch { /* 이미 죽었으면 0 이 아닌 값으로 끝난다 — 그건 탈이 아니다 */ }
129
+ } else if (kid.pid) {
130
+ /*
131
+ * 유닉스는 **무리째** 죽인다.
132
+ *
133
+ * 음수 pid 는 '그 무리 전부' 라는 뜻이다(띄우기옵션 의 detached 와 짝).
134
+ * 이걸 안 하고 kid.kill() 만 하면 sh 나 npm 만 죽고 그 아래 vite 가
135
+ * 포트를 문 채로 남는다 — 무엇이 물고 있는지 못 찾는, 제일 나쁜 자리다.
136
+ *
137
+ * 먼저 곱게 말하고(TERM), 안 들으면 끊는다(KILL). 곧장 KILL 로 가면
138
+ * dev 서버가 임시 파일이나 소켓을 치울 틈이 없다.
139
+ */
140
+ if (곧장) {
141
+ try { process.kill(-kid.pid, 'SIGKILL'); } catch { /* 이미 죽음 */ }
142
+ } else {
143
+ try { process.kill(-kid.pid, 'SIGTERM'); } catch { /* 무리가 없거나 이미 죽음 */ }
144
+ try {
145
+ const 시계 = setTimeout(() => { try { process.kill(-kid.pid, 'SIGKILL'); } catch { /* 죽었다 */ } }, 800);
146
+ 시계.unref?.();
147
+ } catch { /* 끝나는 중이면 타이머를 못 건다 — 부르는 쪽이 곧장 으로 다시 온다 */ }
148
+ }
149
+ }
150
+ try { kid.kill(); } catch { /* 이미 죽음 */ }
151
+ if (끊을까) 파이프끊기(kid);
152
+ try { kid.unref(); } catch { /* 없으면 그만 */ }
153
+ }
154
+
155
+ /**
156
+ * 파이프를 놓는다.
157
+ *
158
+ * 아이가 죽어도 stdout·stderr 를 붙들고 있으면 그 손잡이가 이벤트 루프를
159
+ * 붙잡는다. unref() 는 프로세스 손잡이만 놓지 파이프는 안 놓는다.
160
+ * 이걸 빠뜨리면 deel 이 할 일을 다 하고도 안 끝난다 — 사람 눈에는 멈춘 것이다.
161
+ *
162
+ * 다만 **죽이자마자 끊으면 안 된다.** 그 순간 파이프에는 아직 안 읽힌 것이
163
+ * 남아 있고, 죽기 직전에 나온 몇 줄이 대개 제일 중요하다(서버가 뻗으며 남긴
164
+ * 스택 트레이스가 거기 있다). 그래서 끝내기() 는 잠깐 기다렸다가 이걸 부른다.
165
+ */
166
+ function 파이프끊기(kid) {
167
+ try { kid?.stdout?.destroy(); } catch { /* 이미 닫힘 */ }
168
+ try { kid?.stderr?.destroy(); } catch { /* 이미 닫힘 */ }
169
+ }
170
+
171
+ /** 아직 살아 있나. 종료코드도 시그널도 없으면 안 죽은 것이다. */
172
+ function 아직사나(kid) {
173
+ return !!kid && kid.exitCode == null && kid.signalCode == null;
174
+ }
175
+
176
+ /**
177
+ * 죽인 뒤, 남은 말이 다 나오기를 잠깐 기다린다.
178
+ *
179
+ * 'close' 는 프로세스가 끝나고 **파이프까지 다 비워졌을 때** 온다. 그래서
180
+ * 이걸 기다리면 죽는 순간 뱉은 것까지 손에 들어온다.
181
+ *
182
+ * 무한정 기다리지는 않는다 — 안 죽는 놈 하나가 그 턴을 통째로 잡아먹으면
183
+ * 안 된다. 그리고 여기 시계는 unref 하지 않는다. 이 기다림이 지금 이벤트
184
+ * 루프가 살아 있을 유일한 이유일 수 있어서, 놓아 버리면 그대로 끝난다.
185
+ */
186
+ function 죽는말기다리기(kid, ms) {
187
+ return new Promise((끝) => {
188
+ if (!kid || ms <= 0) return 끝();
189
+ let 됐나 = false;
190
+ const 마치기 = () => { if (됐나) return; 됐나 = true; clearTimeout(시계); 끝(); };
191
+ const 시계 = setTimeout(마치기, ms);
192
+ kid.once('close', 마치기);
193
+ kid.once('error', 마치기);
194
+ });
195
+ }
196
+
197
+ class 일감 {
198
+ constructor(번호, 명령, 설명) {
199
+ this.번호 = 번호;
200
+ this.명령 = 명령;
201
+ this.설명 = 설명 ?? null;
202
+ this.띄운때 = Date.now();
203
+ this.상태 = '도는중'; // 도는중 | 끝남 | 죽임
204
+ this.종료코드 = null;
205
+ this.시그널 = null;
206
+ this.조각들 = [];
207
+ this.바이트 = 0;
208
+ // 이 일감이 앞을 버린 적이 **있다**. 통째로 달라고 할 때의 사실이라,
209
+ // 한 번 서면 안 내려간다 — 지금 들고 있는 것이 처음부터가 아니라는 뜻이다.
210
+ this.앞잘림 = false;
211
+ // 지난번 읽어 간 뒤로 버렸다. **이번에 건네는 토막**에 대한 사실이라,
212
+ // 한 번 말하면 내린다. 이 둘을 한 칸으로 합치면 안 된다 (읽기() 참고).
213
+ this.막버렸다 = false;
214
+ this.읽은글자 = 0;
215
+ // 한 번 정하면 안 바꾼다. 바뀌면 읽은 자리가 조용히 어긋난다 (전체글 참고).
216
+ this.인코딩 = null;
217
+ // 담을 때마다 는다. 푼 글을 다시 써도 되는지를 이 숫자로 가른다 —
218
+ // 바이트 수로 가르면 버린 만큼 다시 담겼을 때 같은 값이 되어 못 알아챈다.
219
+ this.판 = 0;
220
+ this.푼것 = null;
221
+ this.kid = null;
222
+ }
223
+
224
+ 담기(buf) {
225
+ if (!buf?.length) return;
226
+ this.조각들.push(Buffer.from(buf));
227
+ this.바이트 += buf.length;
228
+ this.판++;
229
+ if (this.바이트 <= 출력상한) return;
230
+ /*
231
+ * 앞을 버린다.
232
+ *
233
+ * 버리면 이미 읽은 글자 수가 뜻을 잃는다 — 앞이 사라졌으니 같은 자리가
234
+ * 아니다. 억지로 맞추려 들면 조용히 어긋난 글을 주게 되므로, 커서를
235
+ * 0 으로 돌리고 **앞이 잘렸다고 말한다.** 겹쳐 보이는 편이 낫다.
236
+ */
237
+ while (this.바이트 > 출력상한 && this.조각들.length > 1) {
238
+ this.바이트 -= this.조각들.shift().length;
239
+ }
240
+ this.앞잘림 = true;
241
+ this.막버렸다 = true;
242
+ this.읽은글자 = 0;
243
+ }
244
+
245
+ /*
246
+ * 쌓인 것을 글로 푼다.
247
+ *
248
+ * 조각마다 풀면 여러 바이트짜리 글자가 조각 경계에서 쪼개져 깨진다.
249
+ * 통째로 이어 붙여 한 번에 푼다.
250
+ *
251
+ * **인코딩은 한 번 정하면 안 바꾼다.** 이게 중요하다 — 읽은 자리(읽은글자)를
252
+ * 글자 수로 세고 있는데, 인코딩이 중간에 바뀌면 같은 앞부분이 다른 글자 수로
253
+ * 풀린다. 그러면 커서가 가리키던 자리가 조용히 어긋나서, 이미 준 글을 다시
254
+ * 주거나 새 글을 건너뛴다. 오류도 안 나고 로그에도 안 남는다.
255
+ *
256
+ * 실제로 그럴 수 있다. 인코딩 판정은 **버퍼 전체를 보는 어림짐작**이라,
257
+ * UTF-8 로 잘 풀리던 출력에 UTF-8 이 아닌 바이트가 하나 섞이는 순간
258
+ * 통째로 CP949 로 다시 읽힌다 — 한글 윈도우 콘솔에서 흔한 일이다.
259
+ */
260
+ 전체글() {
261
+ if (!this.조각들.length) return '';
262
+ /*
263
+ * 안 바뀌었으면 지난번 것을 그대로 준다.
264
+ *
265
+ * `목록()` 이 일감마다 이걸 부른다 — 안 읽은 글자 수를 세려고. 그 한 번이
266
+ * 최대 256KB 를 이어 붙이고 통째로 다시 푸는 일이라, 끝난 것 여덟 개를
267
+ * 들고 있으면 목록 한 번에 2MB 를 푼다. 모델이 Jobs 를 부를 때마다.
268
+ */
269
+ if (this.푼것 && this.푼것.판 === this.판) return this.푼것.글;
270
+ const 다 = Buffer.concat(this.조각들);
271
+ if (this.인코딩) {
272
+ try {
273
+ const 글 = new TextDecoder(this.인코딩, { fatal: false }).decode(다);
274
+ this.푼것 = { 판: this.판, 글 };
275
+ return 글;
276
+ } catch { /* 이 Node 가 모르는 이름이면 아래로 내려가 다시 잰다 */ }
277
+ }
278
+ const 콘솔 = consoleCodepage() === 65001 ? 'utf-8' : null;
279
+ try {
280
+ const r = decodeBytes(다, { fallback: 콘솔 });
281
+ // 처음 한 번만 못 박는다. 아직 아무것도 안 쌓였을 때 정하면
282
+ // 표본이 너무 적어 엉뚱하게 잡히므로, 실제로 글이 나온 뒤에 정한다.
283
+ if (r.text) this.인코딩 = r.encoding;
284
+ this.푼것 = { 판: this.판, 글: r.text };
285
+ return r.text;
286
+ } catch { return 다.toString('utf8'); }
287
+ }
288
+
289
+ /**
290
+ * 지난번 읽은 뒤로 새로 나온 것만. 처음부터를 주면 통째로.
291
+ *
292
+ * `앞잘림` 은 **지금 건네는 이 글**에 대한 말이다. 묻는 방식에 따라 답이 다르다.
293
+ *
294
+ * 통째로 — '이 일감은 앞을 버린 적이 있다.' 지금 주는 것이 처음부터가
295
+ * 아니라는 뜻이라, 버린 적이 있는 한 계속 사실이다.
296
+ * 새것만 — '지금 주는 이 토막 **앞에 구멍이 있다.**' 버린 직후 한 번만
297
+ * 사실이고, 그 뒤에 나온 몇 줄은 멀쩡하다.
298
+ *
299
+ * 이 둘을 한 칸으로 합쳐 뒀더니, 한 번 넘친 일감은 **그 뒤로 영원히**
300
+ * "앞부분이 잘렸습니다" 를 달고 나왔다. 모델은 제가 받은 글에 앞이 빠졌다고
301
+ * 믿고 처음부터 다시 읽으러 가는데 — 로컬 모델에서 왕복 하나가 20~40초다 —
302
+ * 그렇게 받아 온 것에도 똑같이 그 말이 붙어 있다. 맴돌기 딱 좋은 자리였다.
303
+ * test/jobs.test.js 의 '온전한 새 출력에 잘렸다고 안 한다' 가 여기를 지킨다.
304
+ */
305
+ 읽기({ 처음부터 = false } = {}) {
306
+ const 글 = this.전체글();
307
+ const 새것 = 처음부터 ? 글 : 글.slice(Math.min(this.읽은글자, 글.length));
308
+ this.읽은글자 = 글.length;
309
+ const 잘림 = 처음부터 ? this.앞잘림 : this.막버렸다;
310
+ // 통째로 읽어 갔으면 지금 들고 있는 것을 다 본 것이라, '바로 앞에 구멍이
311
+ // 있다' 는 경고도 같이 내린다. 다음에 또 버리면 그때 다시 선다.
312
+ this.막버렸다 = false;
313
+ return { 글: 새것, 앞잘림: 잘림 };
314
+ }
315
+
316
+ 산햇수() { return Math.round((Date.now() - this.띄운때) / 1000); }
317
+ }
318
+
319
+ const 일감들 = new Map();
320
+ let 다음번호 = 1;
321
+ // 자리를 위해 지운 개수. **지웠으면 지웠다고 말하려고** 센다 —
322
+ // 안 말하면 모델은 목록에 보이는 것이 전부인 줄 안다.
323
+ let 치운것 = 0;
324
+
325
+ /**
326
+ * 끝난 일감이 쌓이는 것을 막는다.
327
+ *
328
+ * 오래된 것부터 버린다. 번호가 곧 띄운 순서라 그대로 쓴다. 방금 띄운 것을
329
+ * 못 읽게 되면 안 되므로 **최근 쪽을 남긴다.**
330
+ */
331
+ function 끝난것치우기() {
332
+ const 끝난것 = [...일감들.values()].filter((j) => j.상태 !== '도는중').sort((a, b) => a.번호 - b.번호);
333
+ if (끝난것.length <= 끝난것상한) return;
334
+ for (const j of 끝난것.slice(0, 끝난것.length - 끝난것상한)) {
335
+ // 들고 있던 것도 같이 놓는다. 항목만 지우고 조각들을 안 놓으면 아무것도 안 준다.
336
+ j.조각들 = [];
337
+ j.푼것 = null;
338
+ 일감들.delete(j.번호);
339
+ 치운것++;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * 명령을 뒤에서 띄운다.
345
+ *
346
+ * @param 기다림 띄운 뒤 몇 ms 를 지켜보나. 그 사이에 죽으면 **띄우기 실패**로
347
+ * 돌려준다. 포트가 이미 물려 있는 경우가 제일 흔한데, 그때 "떴습니다" 라고
348
+ * 답하면 모델은 다음 단계로 넘어가고 사용자는 안 뜬 서버를 찾아다닌다.
349
+ */
350
+ export async function 띄우기(명령, { cwd, 설명 = null, 기다림 = 1500 } = {}) {
351
+ const 도는것 = [...일감들.values()].filter((j) => j.상태 === '도는중');
352
+ if (도는것.length >= 최대일감) {
353
+ return { error: `뒤에서 도는 명령이 이미 ${도는것.length}개입니다. Jobs 로 안 쓰는 것을 끝내고 다시 하세요.` };
354
+ }
355
+
356
+ const j = new 일감(다음번호++, 명령, 설명);
357
+ const shell = 셸명령(명령);
358
+ let kid;
359
+ try {
360
+ kid = spawn(shell.file, shell.args, {
361
+ ...띄우기옵션(cwd),
362
+ windowsVerbatimArguments: shell.verbatim,
363
+ });
364
+ } catch (err) {
365
+ return { error: `띄우지 못했습니다: ${String(err?.message ?? err)}` };
366
+ }
367
+ j.kid = kid;
368
+ 일감들.set(j.번호, j);
369
+
370
+ kid.stdout?.on('data', (b) => j.담기(b));
371
+ kid.stderr?.on('data', (b) => j.담기(b));
372
+ kid.on('error', (err) => { j.담기(Buffer.from(`\n[띄우기 실패: ${err.message}]\n`)); j.상태 = '끝남'; j.종료코드 = -1; });
373
+ kid.on('close', (code, sig) => {
374
+ if (j.상태 === '죽임') return;
375
+ j.상태 = '끝남';
376
+ j.종료코드 = sig ? null : code;
377
+ j.시그널 = sig ?? null;
378
+ // 끝난 것이 쌓이지 않게. 도는 것은 최대일감 이 따로 막는다.
379
+ 끝난것치우기();
380
+ });
381
+
382
+ // 뜨자마자 죽는지 잠깐 지켜본다. 그 몇 줄이 대부분의 답이다.
383
+ await 잠깐(j, 기다림);
384
+
385
+ const 본것 = j.읽기({ 처음부터: true });
386
+ if (j.상태 !== '도는중') {
387
+ 일감들.delete(j.번호);
388
+ return {
389
+ 떴나: false,
390
+ 번호: j.번호,
391
+ 종료코드: j.종료코드,
392
+ 시그널: j.시그널,
393
+ 출력: 본것.글,
394
+ };
395
+ }
396
+ return { 떴나: true, 번호: j.번호, 출력: 본것.글 };
397
+ }
398
+
399
+ function 잠깐(j, ms) {
400
+ return new Promise((끝) => {
401
+ if (ms <= 0) return 끝();
402
+ let 됐나 = false;
403
+ const 마치기 = () => { if (됐나) return; 됐나 = true; clearTimeout(시계); 끝(); };
404
+ const 시계 = setTimeout(마치기, ms);
405
+ 시계.unref?.();
406
+ j.kid?.once('close', 마치기);
407
+ j.kid?.once('error', 마치기);
408
+ });
409
+ }
410
+
411
+ export function 목록() {
412
+ return [...일감들.values()].map((j) => ({
413
+ 번호: j.번호,
414
+ 명령: j.명령,
415
+ 설명: j.설명,
416
+ 상태: j.상태,
417
+ 종료코드: j.종료코드,
418
+ 시그널: j.시그널,
419
+ 초: j.산햇수(),
420
+ 안읽은글자: Math.max(0, j.전체글().length - j.읽은글자),
421
+ }));
422
+ }
423
+
424
+ export function 하나(번호) { return 일감들.get(Number(번호)) ?? null; }
425
+
426
+ export function 읽기(번호, opts) {
427
+ const j = 하나(번호);
428
+ if (!j) return null;
429
+ const r = j.읽기(opts);
430
+ return { ...r, 상태: j.상태, 종료코드: j.종료코드, 시그널: j.시그널, 명령: j.명령, 초: j.산햇수() };
431
+ }
432
+
433
+ /**
434
+ * 하나를 끝낸다.
435
+ *
436
+ * **기다렸다가 거둔다.** 죽이자마자 파이프를 끊으면 그 순간 아직 안 읽힌 것이
437
+ * 통째로 사라지는데, 하필 그게 제일 중요한 몇 줄이다 — 서버가 뻗으면서 남긴
438
+ * 스택 트레이스, 왜 죽었는지 적은 마지막 말. `마지막 출력:` 이라고 적어 놓고
439
+ * 정작 마지막을 안 주면 안 적느니만 못하다.
440
+ */
441
+ export async function 끝내기(번호) {
442
+ const j = 하나(번호);
443
+ if (!j) return null;
444
+ if (j.상태 !== '도는중') { 일감들.delete(j.번호); return { 이미: true, 명령: j.명령 }; }
445
+ j.상태 = '죽임';
446
+ 나무죽이기(j.kid, { 파이프끊기: false });
447
+ await 죽는말기다리기(j.kid, 죽는말기다림);
448
+ /*
449
+ * 아직 살아 있으면 여기서 끊는다.
450
+ *
451
+ * 곧 목록에서 지울 참인데, 지우고 나면 이놈을 가리킬 방법이 없다. 안 죽은
452
+ * 채로 지우면 영영 못 찾는 프로세스가 하나 남는다 — 이 기능이 없애려던
453
+ * 바로 그 상태다. 유닉스에서 SIGTERM 을 안 듣는 놈이 여기로 온다.
454
+ */
455
+ if (아직사나(j.kid)) 나무죽이기(j.kid, { 파이프끊기: false, 곧장: true });
456
+ 파이프끊기(j.kid);
457
+ const 남은 = j.읽기({});
458
+ 일감들.delete(j.번호);
459
+ return { 이미: false, 명령: j.명령, 초: j.산햇수(), 남은: 남은.글 };
460
+ }
461
+
462
+ /**
463
+ * 전부 끝낸다. deel 을 닫을 때 부른다.
464
+ *
465
+ * 이걸 빠뜨리면 사람이 안 띄운 서버가 계속 돈다. 다음에 켜서 `npm run dev` 를
466
+ * 하면 "포트가 이미 쓰이는 중" 이 뜨는데, 무엇이 물고 있는지 알 길이 없다.
467
+ */
468
+ export function 모두끝내기() {
469
+ const n = [...일감들.values()].filter((j) => j.상태 === '도는중').length;
470
+ for (const j of 일감들.values()) {
471
+ if (j.상태 === '도는중') { j.상태 = '죽임'; 나무죽이기(j.kid); }
472
+ }
473
+ 일감들.clear();
474
+ return n;
475
+ }
476
+
477
+ /** 검사에서 판을 깨끗이 하려고 쓴다. */
478
+ export function 비우기() { 모두끝내기(); 다음번호 = 1; 치운것 = 0; }
479
+
480
+ // 어떤 길로 끝나든 남기지 않는다. repl·oneshot 이 부르는 것과 겹쳐도 무해하다.
481
+ process.once('exit', () => { try { 모두끝내기(); } catch { /* 끝나는 중이라 할 수 있는 게 없다 */ } });
482
+
483
+ /*
484
+ * ── 도구 ──────────────────────────────────────────────────────────────
485
+ *
486
+ * 왜 Bash 안에 인자로 안 넣고 도구를 따로 두나:
487
+ * Bash 에 `job`·`kill` 같은 인자를 얹으면 스키마가 "명령을 실행한다" 와
488
+ * "일감을 본다" 두 가지를 한꺼번에 말하게 된다. 작은 모델은 그 자리에서
489
+ * command 없이 Bash 를 부르거나, 반대로 읽으려다 명령을 또 실행한다.
490
+ * 하는 일이 다르면 도구를 나누는 편이 결국 싸다. 이 스키마는 아주 작다.
491
+ */
492
+ /*
493
+ * 인자 이름 받이 — 한글 이름과 영문 이름을 **둘 다** 받는다.
494
+ *
495
+ * 모델은 한글 인자 이름을 자주 영어로 바꿔 보낸다. 추정이 아니라 이 저장소가
496
+ * 겪은 일이다 — Task 는 이미 `목적 ?? purpose`, `할일 ?? task`, `모드 ?? mode`
497
+ * 로 둘 다 받고 있다(agent/loop.js). 누군가 겪고 달아 둔 것이다.
498
+ *
499
+ * 여기에는 그게 없어서 `Jobs({job: 1, stop: true})` 가 **목록**을 돌려줬다.
500
+ * 모델은 끄라고 시켰고 성공처럼 보이는 답을 받았는데 서버는 그대로 포트를
501
+ * 문다 — 사람은 원인을 못 찾는다. 이 도구에서 제일 나쁜 실패다.
502
+ *
503
+ * 이름을 영문으로 **바꾸는** 것으로는 안 된다. 그러면 한글로 보내는 쪽이 같은
504
+ * 구멍에 빠지고, 안 맞춘 이름(id·number)은 여전히 샌다. 둘 다 받고,
505
+ * 못 알아들은 것은 못 알아들었다고 말한다.
506
+ *
507
+ * **목록은 여기 한 벌뿐이다.** 화면 이름표도 이걸 쓴다(repl.js·oneshot.js).
508
+ * 두 벌이 되면 한쪽만 고쳐지고, 그때부터 화면과 실제가 어긋난다.
509
+ */
510
+ const 인자이름 = {
511
+ 번호: ['번호', 'job', 'job_id', 'jobId', 'id', 'number'],
512
+ 끝내기: ['끝내기', 'stop', 'kill'],
513
+ 처음부터: ['처음부터', 'from_start', 'fromStart', 'full'],
514
+ };
515
+ const 아는이름 = new Set(Object.values(인자이름).flat());
516
+
517
+ /**
518
+ * 준 인자에서 아는 것만 골라낸다.
519
+ *
520
+ * @returns {{번호: number|null, 끝내기: boolean, 처음부터: boolean, 준것: string[], 모르는것: string[]}}
521
+ */
522
+ export function 일감인자(args) {
523
+ const 준것 = Object.keys(args ?? {});
524
+ const 하나씩 = (이름) => {
525
+ for (const n of 인자이름[이름]) if (args?.[n] != null) return args[n];
526
+ return null;
527
+ };
528
+ const 날번호 = 하나씩('번호');
529
+ return {
530
+ 번호: 날번호 == null ? null : Number(날번호),
531
+ 끝내기: !!하나씩('끝내기'),
532
+ 처음부터: !!하나씩('처음부터'),
533
+ 준것,
534
+ 모르는것: 준것.filter((k) => !아는이름.has(k)),
535
+ };
536
+ }
537
+
538
+ /**
539
+ * 모델에게 건넬 만큼만 남긴다 — **뒤**를 남긴다.
540
+ *
541
+ * 오래 도는 것에서 사람이 찾는 것은 언제나 방금 나온 쪽이다. 앞을 남기면
542
+ * 몇 시간 전 기동 로그를 주게 된다.
543
+ *
544
+ * 줄였으면 줄였다고 적는다. 안 적으면 모델은 그게 전부인 줄 알고,
545
+ * 안 보이는 줄에 답이 있을 때 엉뚱한 결론으로 간다.
546
+ */
547
+ function 뒤만(글, 한도 = 건넬상한) {
548
+ const s = String(글 ?? '');
549
+ if (s.length <= 한도) return { 글: s, 줄임: false };
550
+ return {
551
+ 글: `(앞 ${s.length - 한도}자는 길어서 줄였습니다 — 뒤쪽만 보여 줍니다)\n${s.slice(-한도)}`,
552
+ 줄임: true,
553
+ };
554
+ }
555
+
556
+ /** 끝난 일감을 한 마디로. 시그널로 죽으면 종료코드가 null 이라 따로 말한다. */
557
+ function 끝난말(r) {
558
+ if (r.시그널) return `${r.시그널} 로 죽음`;
559
+ return r.종료코드 === 0 ? '끝남' : `종료코드 ${r.종료코드}`;
560
+ }
561
+
562
+ export const JOBS_TOOL = {
563
+ schema: {
564
+ name: 'Jobs',
565
+ description: '뒤에서 도는 명령(Bash 의 background)을 보고·읽고·끝낸다.'
566
+ + ' 번호 없이 부르면 목록. 번호를 주면 그동안 새로 나온 출력.'
567
+ + ' 서버를 띄웠으면 일이 끝날 때 반드시 끝내기로 정리해라.',
568
+ parameters: {
569
+ type: 'object',
570
+ properties: {
571
+ 번호: { type: 'number', description: '볼 일감 번호 (job). 없으면 목록' },
572
+ 끝내기: { type: 'boolean', description: 'true 면 그 일감을 끝낸다 (stop)' },
573
+ 처음부터: { type: 'boolean', description: 'true 면 처음부터 다시 읽는다 (from_start)' },
574
+ },
575
+ required: [],
576
+ },
577
+ },
578
+ // 끝내기 는 죽는 순간 뱉은 말을 기다렸다 거두므로 비동기다 (끝내기 머리말 참고).
579
+ // runTool 이 await 로 부르니 도구가 비동기여도 된다 (tools/index.js).
580
+ async run(args) {
581
+ const 받은것 = 일감인자(args);
582
+ /*
583
+ * 준 것이 있는데 **하나도** 못 알아들었으면 목록으로 얼버무리면 안 된다.
584
+ *
585
+ * 목록을 돌려주면 그것도 성공한 답으로 보인다. 끄라고 시킨 모델은
586
+ * 껐다고 여기고 넘어가는데 서버는 그대로 돈다. 무엇을 받는지 알려 줘야
587
+ * 다음 걸음에서 고쳐 부른다.
588
+ *
589
+ * 인자가 **아예 없는** 것은 그냥 '목록 보기' 다 — 그건 오류가 아니다.
590
+ */
591
+ if (받은것.준것.length && 받은것.모르는것.length === 받은것.준것.length) {
592
+ return {
593
+ error: `모르는 인자입니다: ${받은것.모르는것.join(', ')}.`
594
+ + ' Jobs 는 번호(job) · 끝내기(stop) · 처음부터(from_start) 만 받습니다.'
595
+ + ' 번호 없이 부르면 목록입니다.',
596
+ };
597
+ }
598
+ const 번호 = 받은것.번호;
599
+ // 숫자가 아닌 것을 받으면 NaN 이 되고, 그대로 두면 `NaN번 일감이 없습니다`
600
+ // 라는 말이 모델에게 나간다. 무엇이 잘못됐는지 안 알려 주는 오류다.
601
+ if (번호 != null && !Number.isFinite(번호)) {
602
+ return { error: `번호는 숫자여야 합니다 (받은 것: ${JSON.stringify(args?.번호)}). 번호 없이 Jobs 를 불러 목록을 보세요.` };
603
+ }
604
+
605
+ if (번호 == null) {
606
+ /*
607
+ * 번호 없이 "끝내라" 고 하면 목록으로 얼버무리면 안 된다.
608
+ *
609
+ * 목록을 돌려주면 그건 성공한 답으로 보인다. 모델은 정리한 줄 알고
610
+ * "서버를 껐습니다" 라고 말하는데 서버는 그대로 돌고 있다.
611
+ * 도구가 시킨 일을 안 했으면 안 했다고 말해야 그 다음이 이어진다.
612
+ */
613
+ if (받은것.끝내기) {
614
+ const 도는것 = 목록().filter((j) => j.상태 === '도는중');
615
+ return {
616
+ error: '끝낼 일감 번호를 주세요. 번호 없이 끝내기만 주면 아무것도 안 끝냅니다.'
617
+ + (도는것.length
618
+ ? ` 지금 도는 것: ${도는것.map((j) => `${j.번호}번(${j.명령})`).join(' · ')}`
619
+ : ' 지금 도는 것이 없습니다.'),
620
+ };
621
+ }
622
+ const ls = 목록();
623
+ if (!ls.length) return { content: '뒤에서 도는 명령이 없습니다.', summary: '0개' };
624
+ const 줄들 = ls.map((j) => {
625
+ const 상 = j.상태 === '도는중' ? `도는중 ${j.초}초` : j.시그널 ? `${j.시그널} 로 죽음` : `끝남 (종료코드 ${j.종료코드})`;
626
+ const 새것 = j.안읽은글자 ? ` · 안 읽은 출력 ${j.안읽은글자}자` : '';
627
+ return ` ${j.번호}. ${j.명령} — ${상}${새것}`;
628
+ });
629
+ /*
630
+ * 자리를 위해 지운 것이 있으면 말한다.
631
+ *
632
+ * 안 말하면 모델은 여기 보이는 것이 이 세션에서 띄운 전부인 줄 안다.
633
+ * 아까 띄운 3번을 찾다가 없으니 "안 띄웠나 보다" 하고 다시 띄운다 —
634
+ * 그러면 포트가 물려서 안 뜨고, 왜 안 되는지도 모른다.
635
+ */
636
+ if (치운것) 줄들.push(` (오래된 것 ${치운것}개는 자리를 위해 지웠습니다)`);
637
+ return { content: 줄들.join('\n'), summary: `${ls.length}개`, 일감수: ls.length };
638
+ }
639
+
640
+ if (받은것.끝내기) {
641
+ const r = await 끝내기(번호);
642
+ if (!r) return { error: `${번호}번 일감이 없습니다. 번호 없이 Jobs 를 불러 목록을 보세요.` };
643
+ if (r.이미) return { content: `${번호}번은 이미 끝나 있었습니다: ${r.명령}`, summary: '이미 끝남' };
644
+ return {
645
+ content: `${번호}번을 끝냈습니다 (${r.초}초 돌았습니다): ${r.명령}`
646
+ + (r.남은 ? `\n\n마지막 출력:\n${뒤만(r.남은, 2000).글}` : ''),
647
+ summary: `끝냄 · ${r.초}초`,
648
+ };
649
+ }
650
+
651
+ const r = 읽기(번호, { 처음부터: 받은것.처음부터 });
652
+ if (!r) return { error: `${번호}번 일감이 없습니다. 번호 없이 Jobs 를 불러 목록을 보세요.` };
653
+ const 머리 = r.상태 === '도는중'
654
+ ? `${번호}번 (도는중 ${r.초}초): ${r.명령}`
655
+ : `${번호}번 (${r.시그널 ? `${r.시그널} 로 죽음` : `끝남 · 종료코드 ${r.종료코드}`}): ${r.명령}`;
656
+ // 새 출력이 없다는 것도 사실이다. 빈 글을 주면 모델은 못 읽은 줄 안다.
657
+ const 잘라낸 = 뒤만(r.글);
658
+ const 몸 = r.글.trim()
659
+ ? (r.앞잘림 ? '(앞부분은 너무 길어 잘렸습니다 — 뒤쪽만 남았습니다)\n' : '') + 잘라낸.글
660
+ : '(지난번 읽은 뒤로 새 출력이 없습니다)';
661
+ return {
662
+ content: `${머리}\n\n${몸}`,
663
+ summary: (r.상태 === '도는중' ? `도는중 · ${r.초}초` : 끝난말(r))
664
+ + (잘라낸.줄임 ? ' · 일부만' : ''),
665
+ // 끝난 일감이 종료코드 0 이 아니면 실패로 물들인다 — Bash 와 같은 규칙이다.
666
+ // 시그널로 죽은 것도 실패다 — 그때 종료코드는 null 이라 !== 0 으로 잡힌다.
667
+ failed: r.상태 !== '도는중' && r.종료코드 !== 0,
668
+ };
669
+ },
670
+ };