deel-local-cli 1.20.13 → 2.0.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 (155) hide show
  1. package/README.ko.md +97 -78
  2. package/README.md +120 -81
  3. package/bin/deel.js +398 -32
  4. package/package.json +2 -1
  5. package/src/acp/jsonrpc.js +87 -7
  6. package/src/acp/map.js +23 -3
  7. package/src/acp/serve.js +333 -25
  8. package/src/agent/agents.js +110 -12
  9. package/src/agent/askcheck.js +50 -6
  10. package/src/agent/asks.js +59 -5
  11. package/src/agent/budget.js +9 -2
  12. package/src/agent/card.js +8 -2
  13. package/src/agent/commit.js +168 -16
  14. package/src/agent/compact.js +93 -21
  15. package/src/agent/{/354/213/240/353/242/260/353/217/204.js → confidence.js} +1 -1
  16. package/src/agent/effort.js +85 -15
  17. package/src/agent/evidence.js +63 -7
  18. package/src/agent/evolve.js +132 -24
  19. package/src/agent/grade.js +12 -3
  20. package/src/agent/loop.js +408 -81
  21. package/src/agent/memory.js +176 -29
  22. package/src/agent/mention.js +22 -5
  23. package/src/agent/models.js +31 -7
  24. package/src/agent/modes.js +25 -10
  25. package/src/agent/outschema.js +381 -56
  26. package/src/agent/pins.js +33 -2
  27. package/src/agent/project.js +73 -8
  28. package/src/agent/recall.js +40 -9
  29. package/src/agent/{/354/204/261/355/225/234/352/270/260/353/241/235.js → recordshape.js} +19 -8
  30. package/src/agent/review.js +143 -13
  31. package/src/agent/route.js +253 -26
  32. package/src/agent/salvage.js +2 -1
  33. package/src/agent/session.js +457 -31
  34. package/src/agent/store.js +232 -22
  35. package/src/agent/threads.js +52 -3
  36. package/src/backend/adapter.js +508 -85
  37. package/src/backend/azure.js +12 -1
  38. package/src/backend/cachemark.js +31 -5
  39. package/src/backend/clientcert.js +23 -1
  40. package/src/backend/ctxsize.js +56 -9
  41. package/src/backend/detect.js +252 -19
  42. package/src/backend/http.js +383 -42
  43. package/src/backend/learn.js +101 -11
  44. package/src/backend/mcp.js +333 -24
  45. package/src/backend/price.js +61 -13
  46. package/src/backend/probe.js +188 -36
  47. package/src/backend/proxy.js +108 -14
  48. package/src/backend/quota.js +226 -30
  49. package/src/backend/retry.js +46 -3
  50. package/src/backend/scan.js +47 -10
  51. package/src/backend/scanui.js +57 -7
  52. package/src/backend/toolfit.js +204 -22
  53. package/src/backend/vision.js +14 -1
  54. package/src/backend/wire.js +58 -10
  55. package/src/cmdnames.js +16 -0
  56. package/src/commands/common.js +70 -0
  57. package/src/commands/extend.js +299 -0
  58. package/src/commands/model.js +851 -0
  59. package/src/commands/view.js +328 -0
  60. package/src/commands/work.js +823 -0
  61. package/src/commands.js +193 -2082
  62. package/src/completion.js +45 -3
  63. package/src/config.js +454 -41
  64. package/src/configexplain.js +127 -15
  65. package/src/doctor.js +164 -27
  66. package/src/i18n/en.js +22 -0
  67. package/src/i18n/index.js +31 -1
  68. package/src/i18n/ja.js +25 -1
  69. package/src/i18n/ko.js +25 -2
  70. package/src/i18n/zh.js +25 -1
  71. package/src/lsp/client.js +111 -9
  72. package/src/lsp/servers.js +86 -22
  73. package/src/oneshot.js +325 -65
  74. package/src/pack/sbom.js +28 -5
  75. package/src/pack/selfpack.js +54 -5
  76. package/src/pack/sheet.en.js +24 -3
  77. package/src/pack/tar.js +45 -8
  78. package/src/pack/zip.js +103 -8
  79. package/src/plugins/manage.js +165 -14
  80. package/src/preview/serve.js +239 -23
  81. package/src/providers/bedrock.js +30 -5
  82. package/src/providers/gemini.js +8 -0
  83. package/src/providers/index.js +17 -1
  84. package/src/repl.js +254 -61
  85. package/src/report.js +7 -2
  86. package/src/reset.js +151 -25
  87. package/src/safety/audit.js +108 -8
  88. package/src/safety/authcmd.js +75 -5
  89. package/src/safety/guard.js +531 -15
  90. package/src/safety/hooks.js +172 -20
  91. package/src/safety/keystore.js +178 -18
  92. package/src/safety/network.js +209 -6
  93. package/src/safety/policy.js +377 -36
  94. package/src/safety/runmode.js +9 -2
  95. package/src/safety/secrets.js +243 -17
  96. package/src/safety/shellenv.js +64 -3
  97. package/src/safety/trust.js +298 -32
  98. package/src/safety/undo.js +409 -17
  99. package/src/setup.js +175 -17
  100. package/src/skills/discover.js +122 -19
  101. package/src/stats.js +57 -16
  102. package/src/tools/{/355/231/225/354/235/270/353/262/225.js → checkmethods.js} +26 -1
  103. package/src/tools/clipboard.js +53 -20
  104. package/src/tools/convert.js +118 -37
  105. package/src/tools/desc.en.js +48 -5
  106. package/src/tools/doc2md.js +36 -4
  107. package/src/tools/docs.js +94 -18
  108. package/src/tools/edit-match.js +212 -13
  109. package/src/tools/encoding.js +525 -28
  110. package/src/tools/excel-com.js +63 -15
  111. package/src/tools/excel.js +12 -7
  112. package/src/tools/fastgrep.js +501 -38
  113. package/src/tools/fig.js +21 -4
  114. package/src/tools/fsutil.js +235 -21
  115. package/src/tools/hwpxwrite.js +80 -19
  116. package/src/tools/ignore.js +55 -7
  117. package/src/tools/index.js +1083 -148
  118. package/src/tools/jobs.js +328 -50
  119. package/src/tools/kiwi.js +50 -5
  120. package/src/tools/lsp.js +164 -32
  121. package/src/tools/outline.js +52 -12
  122. package/src/tools/pdf.js +367 -59
  123. package/src/tools/shell.js +12 -4
  124. package/src/tools/spawn.js +203 -18
  125. package/src/tools/todo.js +73 -4
  126. package/src/tools/verify.js +328 -45
  127. package/src/tools/webfetch.js +194 -25
  128. package/src/tools/xlsx.js +232 -31
  129. package/src/ui/ansi.js +209 -15
  130. package/src/ui/banner.js +2 -1
  131. package/src/ui/complete.js +46 -4
  132. package/src/ui/diff.js +4 -2
  133. package/src/ui/export.js +91 -17
  134. package/src/ui/inputbox.js +58 -18
  135. package/src/ui/intro.js +9 -2
  136. package/src/ui/level.js +32 -7
  137. package/src/ui/md.js +101 -15
  138. package/src/ui/motion.js +5 -2
  139. package/src/ui/notify.js +7 -1
  140. package/src/ui/office.js +37 -5
  141. package/src/ui/pastechip.js +3 -2
  142. package/src/ui/pick.js +85 -12
  143. package/src/ui/prompt.js +176 -13
  144. package/src/ui/screen.js +2 -1
  145. package/src/ui/spinner.js +6 -1
  146. package/src/ui/status.js +80 -12
  147. package/src/ui/wrap.js +23 -37
  148. /package/src/agent/{/353/213/250/352/263/204.js" → phase.js} +0 -0
  149. /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" → build-deep/SKILL.md} +0 -0
  150. /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" → debug-step-by-step/SKILL.md} +0 -0
  151. /package/src/skills/builtin/{/353/201/235/352/271/214/354/247/200-/355/225/230/352/270/260/SKILL.md" → finish-all/SKILL.md} +0 -0
  152. /package/src/skills/builtin/{/354/212/244/354/212/244/353/241/234-/352/262/200/355/206/240/SKILL.md" → self-review/SKILL.md} +0 -0
  153. /package/src/skills/builtin/{/354/275/224/353/223/234-/354/244/204/354/235/264/352/270/260/SKILL.md" → simplify-code/SKILL.md} +0 -0
  154. /package/src/skills/builtin/{/354/260/224/353/237/254/353/263/264/352/270/260/SKILL.md" → spike/SKILL.md} +0 -0
  155. /package/src/skills/builtin/{/352/262/200/354/202/254-/353/250/274/354/240/200/SKILL.md" → test-first/SKILL.md} +0 -0
package/src/tools/jobs.js CHANGED
@@ -23,7 +23,7 @@
23
23
  * 그건 '내가 안 띄운 서버가 포트를 물고 있는' 상태고, 원인을 못 찾는다.
24
24
  * · 출력은 상한이 있다. watch 하나가 몇 시간 돌면 몇 GB 가 된다.
25
25
  */
26
- import { spawn, execFileSync } from 'node:child_process';
26
+ import { spawn } from 'node:child_process';
27
27
  import { decode as decodeBytes, consoleCodepage } from './encoding.js';
28
28
  import { 셸명령 } from './shell.js';
29
29
  import { 셸환경 } from '../safety/shellenv.js';
@@ -131,15 +131,39 @@ export function 띄우기옵션(cwd, { 남길것 = [] } = {}) {
131
131
  *
132
132
  * 갈리는 자리는 **signal 과 code** 다. 아래 검사(test/jobs.test.js 의
133
133
  * '★ taskkill 이 잘린 것과 이미 죽은 것을 가른다')가 이 값을 못박아 둔다.
134
+ *
135
+ * ── 상한에 걸려도 taskkill 을 **죽이지 않는다** (2.0.0 6회차) ─────────────────
136
+ *
137
+ * 위처럼 execFileSync 의 timeout 으로 부르면, 상한에 걸리는 순간 Node 가 taskkill 을 죽인다.
138
+ * 한가할 때는 그 순간 taskkill 이 아직 아무것도 안 죽인 채라 괜찮았다. 그런데 컴퓨터가
139
+ * 바쁘면 taskkill 이 **나무를 반쯤 죽인 채** 죽는다 — Git Bash 나무(`bin/bash.exe →
140
+ * usr/bin/bash.exe → node`)에서 뿌리와 가운데 셸만 죽고 손자가 부모 없이 남았다. 그 뒤로는
141
+ * 손자를 가리킬 길이 없어(남은무리끊기 머리말) 두 번째 부름도 「프로세스 없음」 으로 끝났다.
142
+ * 코어를 다 쓰는 판에서 끝내기 4번 중 1번 · 모두끝내기 4번 중 1번이 dev 서버를 남겼고,
143
+ * 검사를 돌린 하루 동안 jobs 검사의 아이 74개가 그렇게 살아 있었다.
144
+ *
145
+ * 그래서 떼어서(detached) 띄우고 **상한까지만 동기로 지켜본다.** 그 안에 끝나면 다 훑은 것,
146
+ * 못 끝나면 「잘렸다」 로 돌려주되 taskkill 은 제 할 일을 끝까지 한다 — deel 이 끝나는
147
+ * 길이어도 떼어 띄운 것은 살아남는다. 돌려주는 뜻(잘렸으면 뿌리를 살려 두고 다시 온다)은 같다.
148
+ * 잘렸나() 는 execFileSync 오류를 가르는 판별식으로 그대로 둔다 (검사가 값을 못박아 둔다).
134
149
  */
135
150
  export function 나무끊기(pid, 상한 = 1500) {
136
151
  if (process.platform !== 'win32' || !pid) return true;
152
+ let 끊개;
137
153
  try {
138
- execFileSync('taskkill', ['/pid', String(pid), '/t', '/f'],
139
- { windowsHide: true, stdio: 'ignore', timeout: 상한 });
140
- return true;
141
- } catch (err) {
142
- return !잘렸나(err);
154
+ 끊개 = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore', detached: true });
155
+ } catch {
156
+ return true; // taskkill 을 못 띄운 판 — 예전에도 「잘림」 이 아닌 실패는 다 참이었다
157
+ }
158
+ 끊개.once('error', () => { /* 못 띄웠다 — 위와 같다 */ });
159
+ 끊개.unref();
160
+ if (!끊개.pid) return true;
161
+ const 끝 = Date.now() + 상한;
162
+ for (;;) {
163
+ // 신호 0 은 「살아 있나」 만 묻는다. 끝났으면 던진다 — 없는 pid 로 끝난 것(이미 죽음)도 여기로 온다.
164
+ try { process.kill(끊개.pid, 0); } catch { return true; }
165
+ if (Date.now() >= 끝) return false;
166
+ 동기로쉬기(15);
143
167
  }
144
168
  }
145
169
 
@@ -148,30 +172,24 @@ export function 잘렸나(err) {
148
172
  return err?.code === 'ETIMEDOUT' || err?.signal != null || err?.killed === true;
149
173
  }
150
174
 
151
- /**
152
- * 유닉스에서 pid 가 이끄는 **무리째** 죽인다.
153
- *
154
- * 음수 pid 는 '그 무리 전부' 라는 뜻이다 — 띄울 때 detached 로 무리를 만들어
155
- * 둔 것과 짝이다. 이걸 안 하고 kid.kill() 만 하면 sh 나 npm 만 죽고 그 아래
156
- * vite 가 포트를 문 채로 남는다. 무엇이 물고 있는지 못 찾는, 제일 나쁜 자리다.
157
- *
158
- * 먼저 곱게 말하고(TERM), 안 들으면 끊는다(KILL). 곧장 KILL 로 가면 dev 서버가
159
- * 임시 파일이나 소켓을 치울 틈이 없다.
160
- *
161
- * **띄울 때 무리를 안 만들었으면 여기서 할 수 있는 것이 없다.** 나중에 무리를
162
- * 만들어 줄 방법은 없다 — 그래서 이 판단이 띄우는 자리와 짝을 이뤄야 한다.
163
- * (Bash 도구가 한동안 이 짝을 안 맞춰서, 윈도우만 고쳐지고 유닉스는 그대로였다.)
164
- */
165
175
  /**
166
176
  * 그 무리가 아직 살아 있나. 죽었으면 false.
167
177
  *
168
- * 무리로 못 물어보면 **그 하나에게** 물어본다 — 위 신호주기() 와 같은 까닭이다.
178
+ * 무리로 못 물어보면 **그 하나에게** 물어본다 — 아래 신호주기() 와 같은 까닭이다.
169
179
  * 무리가 없는 판에서 「무리가 없다 = 다 죽었다」 로 읽으면, 살아 있는 자식을
170
180
  * 죽은 것으로 세고 그냥 지나간다.
171
181
  */
172
- function 무리살아있나(pid) {
173
- try { process.kill(-pid, 0); return true; } catch { /* 무리가 없다 */ }
174
- try { process.kill(pid, 0); return true; } catch { return false; }
182
+ export function 무리살아있나(pid) {
183
+ /*
184
+ * EPERM 은 「없다」 가 아니라 **「있는데 못 건드린다」** 이다.
185
+ *
186
+ * 여기가 아무 탈이나 다 「죽었다」 로 읽어서, 무리끊기 의 지켜보기 고리가
187
+ * 첫 바퀴에 빠져나가고 마지막 SIGKILL 까지 통째로 건너뛰었다 — 그 자리
188
+ * 머리말이 「권한이 없으면 살아 있는데도 「없다」 가 나오고, 그러면 안 죽이고
189
+ * 지나간다」 라며 걷어냈다고 적은 바로 그 길이다. 고침이 반만 붙어 있었다.
190
+ */
191
+ try { process.kill(-pid, 0); return true; } catch (탈) { if (탈?.code === 'EPERM') return true; }
192
+ try { process.kill(pid, 0); return true; } catch (탈) { return 탈?.code === 'EPERM'; }
175
193
  }
176
194
 
177
195
  /**
@@ -214,6 +232,21 @@ function 신호주기(pid, 신호) {
214
232
  return 닿았나;
215
233
  }
216
234
 
235
+ /**
236
+ * 유닉스에서 pid 가 이끄는 **무리째** 죽인다.
237
+ *
238
+ * 음수 pid 는 '그 무리 전부' 라는 뜻이다 — 띄울 때 detached 로 무리를 만들어
239
+ * 둔 것과 짝이다. 이걸 안 하고 kid.kill() 만 하면 sh 나 npm 만 죽고 그 아래
240
+ * vite 가 포트를 문 채로 남는다. 무엇이 물고 있는지 못 찾는, 제일 나쁜 자리다.
241
+ *
242
+ * 먼저 곱게 말하고(TERM), 안 들으면 끊는다(KILL). 곧장 KILL 로 가면 dev 서버가
243
+ * 임시 파일이나 소켓을 치울 틈이 없다. 얼마나 기다릴지(`늦게`)는 부르는 쪽이
244
+ * 정한다 — 끝나는 길에서는 짧게, 사람이 「끝내」 라고 시킨 자리에서는 길게.
245
+ *
246
+ * **띄울 때 무리를 안 만들었으면 여기서 할 수 있는 것이 없다.** 나중에 무리를
247
+ * 만들어 줄 방법은 없다 — 그래서 이 판단이 띄우는 자리와 짝을 이뤄야 한다.
248
+ * (Bash 도구가 한동안 이 짝을 안 맞춰서, 윈도우만 고쳐지고 유닉스는 그대로였다.)
249
+ */
217
250
  export function 무리끊기(pid, { 곧장 = false, 늦게 = 800 } = {}) {
218
251
  if (process.platform === 'win32' || !pid) return;
219
252
  if (곧장) {
@@ -241,7 +274,7 @@ export function 무리끊기(pid, { 곧장 = false, 늦게 = 800 } = {}) {
241
274
  * 거의 안 기다린다. 안 죽는 놈에게만 `늦게` 만큼 쓴다.
242
275
  */
243
276
  const 조각 = 20;
244
- for (let 쓴시간 = 0; 쓴시간 < 늦게; 쓴시간 += 조각) {
277
+ for (let 쓴시간 = 0; 쓴시간 < Math.min(늦게, 못재고기다릴최대); 쓴시간 += 조각) {
245
278
  if (!무리살아있나(pid)) return;
246
279
  동기로쉬기(조각);
247
280
  }
@@ -256,6 +289,48 @@ export function 무리끊기(pid, { 곧장 = false, 늦게 = 800 } = {}) {
256
289
  신호주기(pid, 'SIGKILL');
257
290
  }
258
291
 
292
+ /*
293
+ * ── 그 지켜보기는 유닉스에서 **한 번도 안 끝났다** (2.0.0 16회차) ──────────
294
+ *
295
+ * 위 고리의 머리말은 「얌전히 끝나는 무리는 첫 20ms 에 사라지므로 보통은 거의
296
+ * 안 기다린다」 고 적어 뒀다. 윈도우에서만 참인 말이었다.
297
+ *
298
+ * 우리가 띄운 자식은 **죽어도 우리가 거두기(reap) 전까지 좀비로 남는다.**
299
+ * 거두는 일은 이벤트 루프에서만 일어나는데, 위 고리는 `Atomics.wait` 로 그
300
+ * 루프를 세워 놓고 묻는다. `무리살아있나` 는 좀비를 「산다」 로 답하므로
301
+ * (`kill(pid, 0)` 은 좀비에게도 성공한다) 답은 영영 안 바뀌고, 고리는 **늘
302
+ * `늦게` 를 다 쓴다.**
303
+ *
304
+ * 값이 얼마였나. 사람이 「끝내」 를 한 번 칠 때마다 6초였다 — 리눅스 CI 에서
305
+ * test/jobs.test.js 의 「오래된 것에는 안 머문다」(400ms 아래여야 한다)가
306
+ * **6037ms** 로 빨개진 것이 이것이고, 화면에는 아무 말도 안 난다.
307
+ *
308
+ * 그래서 둘로 가른다.
309
+ *
310
+ * · 기다릴 수 있는 자리(끝내기 — async) → 무리끊기기다려(). 진짜로 쉬어
311
+ * 주니 루프가 돌아 자식이 거둬지고, 같은 물음이 그때부터 바르게 답한다.
312
+ * · 못 기다리는 자리(프로그램이 끝나는 길) → 위 동기 고리. 다만 **못 재는
313
+ * 기다림은 최선이 아니라 모두가 내는 값**이라 짧게 못 박는다. SIGKILL 은
314
+ * 그대로 나가므로, 신호를 무시하는 손자를 살려 보내는 일은 없다.
315
+ */
316
+ // 검사도 이 값을 쓴다 (test/jobs.test.js 의 「상한이 유닉스 갈래에서도
317
+ // 지켜진다」). 거기서 400 을 손으로 적어 두면, 이 값을 고친 날 그 검사는
318
+ // **조용히 아무것도 안 가르는 검사**가 된다.
319
+ export const 못재고기다릴최대 = 400;
320
+
321
+ /** 무리끊기 의 **기다릴 수 있는** 판. 까닭은 바로 위 머리말에. */
322
+ export async function 무리끊기기다려(pid, { 곧장 = false, 늦게 = 800 } = {}) {
323
+ if (process.platform === 'win32' || !pid) return;
324
+ if (곧장) { 신호주기(pid, 'SIGKILL'); return; }
325
+ 신호주기(pid, 'SIGTERM');
326
+ const 조각 = 20;
327
+ for (let 쓴시간 = 0; 쓴시간 < 늦게; 쓴시간 += 조각) {
328
+ if (!무리살아있나(pid)) return;
329
+ await new Promise((r) => setTimeout(r, 조각));
330
+ }
331
+ 신호주기(pid, 'SIGKILL'); // 위와 같은 까닭으로 묻지 않고 보낸다
332
+ }
333
+
259
334
  /**
260
335
  * 프로세스 나무를 통째로 끝낸다. 자식만 죽이면 손자가 남아 포트를 계속 문다.
261
336
  *
@@ -272,7 +347,7 @@ export function 무리끊기(pid, { 곧장 = false, 늦게 = 800 } = {}) {
272
347
  * 그래서 동기로 부른다. 여기서 멈추는 몇십 ms 로 '안 죽은 서버' 를 없앤다.
273
348
  * test/jobs.test.js 의 '프로세스가 진짜 멈춘다' 가 이 자리를 지킨다.
274
349
  */
275
- function 나무죽이기(kid, { 파이프끊기: 끊을까 = true, 곧장 = false, 상한 = 1500, 뿌리살리기 = true } = {}) {
350
+ export function 나무죽이기(kid, { 파이프끊기: 끊을까 = true, 곧장 = false, 상한 = 1500, 뿌리살리기 = true } = {}) {
276
351
  if (!kid) return true;
277
352
  let 나무끊었나 = true;
278
353
  if (process.platform === 'win32' && kid.pid) {
@@ -291,7 +366,17 @@ function 나무죽이기(kid, { 파이프끊기: 끊을까 = true, 곧장 = fals
291
366
  */
292
367
  if (!나무끊었나 && 뿌리살리기) return false;
293
368
  } else if (kid.pid) {
294
- 무리끊기(kid.pid, { 곧장 });
369
+ /*
370
+ * **상한은 여기도 넘긴다.** 윈도우 갈래(바로 위)는 부르는 쪽이 정한 상한을
371
+ * 그대로 쓰는데, 유닉스 갈래만 그 값을 버리고 무리끊기의 기본 800ms 를 썼다.
372
+ * 재 보니 상한 5000 을 줘도 100 을 줘도 걸린 시간이 똑같았다 — 위 머리말이
373
+ * 「상한은 부르는 쪽이 정한다」 고 적어 놓고 한쪽에서만 지키고 있었다.
374
+ *
375
+ * 값이 다르다. 끝나는 길(process 'exit')에서는 일감마다 몇 초씩 붙들면 deel 이
376
+ * 멈춘 것처럼 보이고, 사람이 「끝내」 라고 시킨 자리에서는 안 죽는 서버 하나가
377
+ * 훨씬 비싸다. 그 판단을 부르는 쪽이 이미 하고 있었다.
378
+ */
379
+ 무리끊기(kid.pid, { 곧장, 늦게: 상한 });
295
380
  }
296
381
  try { kid.kill(); } catch { /* 이미 죽음 */ }
297
382
  if (끊을까) 파이프끊기(kid);
@@ -299,6 +384,23 @@ function 나무죽이기(kid, { 파이프끊기: 끊을까 = true, 곧장 = fals
299
384
  return 나무끊었나;
300
385
  }
301
386
 
387
+ /**
388
+ * 나무죽이기 의 **기다릴 수 있는** 판 (무리끊기기다려 머리말).
389
+ *
390
+ * 윈도우 갈래는 taskkill 을 이미 동기로 기다리므로 그대로 넘긴다. 유닉스에서만
391
+ * 지켜보기를 진짜 쉬면서 한다 — 그래야 죽은 자식이 거둬지고 「아직 사나」 가
392
+ * 바르게 답한다.
393
+ */
394
+ export async function 나무죽이기기다려(kid, 몫 = {}) {
395
+ const { 파이프끊기: 끊을까 = true, 곧장 = false, 상한 = 1500 } = 몫;
396
+ if (!kid || process.platform === 'win32' || !kid.pid) return 나무죽이기(kid, 몫);
397
+ await 무리끊기기다려(kid.pid, { 곧장, 늦게: 상한 });
398
+ try { kid.kill(); } catch { /* 이미 죽음 */ }
399
+ if (끊을까) 파이프끊기(kid);
400
+ try { kid.unref(); } catch { /* 없으면 그만 */ }
401
+ return true;
402
+ }
403
+
302
404
  /**
303
405
  * 파이프를 놓는다.
304
406
  *
@@ -315,11 +417,45 @@ function 파이프끊기(kid) {
315
417
  try { kid?.stderr?.destroy(); } catch { /* 이미 닫힘 */ }
316
418
  }
317
419
 
420
+ /*
421
+ * ── 갓 띄운 셸은 나무를 **나중에** 세운다 (2.0.0 6회차) ─────────────────────────
422
+ *
423
+ * 윈도우의 Git Bash 는 `bin/bash.exe`(발사대) → `usr/bin/bash.exe` → 명령 으로 한 겹씩 띄운다.
424
+ * 띄우고 몇 ms 안에 taskkill /t 를 보내면 그 순간 있는 나무(발사대뿐)만 죽고, 곧이어 뜬 손자가
425
+ * 부모 없이 남는다. 뿌리가 죽은 뒤로는 그 손자를 가리킬 길이 없다(남은무리끊기 머리말).
426
+ * 재 보니 띄우고 0ms 에 끊으면 4번 중 1~2번, 30ms 에 1번 남았고 150ms 부터 0 이었다(옛 코드도 같음).
427
+ * 바쁜 컴퓨터에서는 그 창이 넓어져 150~600ms 에 끊은 판도 남았다. 검사 판마다 이 파일의 아이가
428
+ * 남던 것이 이것이다.
429
+ *
430
+ * 그래서 끄는 쪽에서 갓 띄운 것에 1초를 채워 준다. 모델이 띄운 것은 띄우기 가 이미 1.5초를
431
+ * 기다렸고 왕복이 수 초라 거의 안 걸린다 — 곧장 끄는 판(사람이 띄우자마자 나감 · 검사)만 기다린다.
432
+ */
433
+ const 나무설틈 = 1000;
434
+
318
435
  /** 아직 살아 있나. 종료코드도 시그널도 없으면 안 죽은 것이다. */
319
436
  function 아직사나(kid) {
320
437
  return !!kid && kid.exitCode == null && kid.signalCode == null;
321
438
  }
322
439
 
440
+ /**
441
+ * 뿌리(셸)는 이미 끝났는데 그 아래 남은 것이 파이프를 물고 있을 때, 남은 것을 끊는다.
442
+ * Bash(tools/index.js)와 끝내기 가 같이 쓴다 (사냥5 M2).
443
+ *
444
+ * 유닉스는 띄울 때 만든 무리가 우두머리가 죽은 뒤에도 남아 있어 `-pid` 로 무리째 닿는다.
445
+ *
446
+ * 윈도우는 **못 한다.** taskkill /t 는 살아 있는 뿌리부터 나무를 훑는데, 뿌리가 끝나면
447
+ * 가리킬 자리가 없다. 부모 번호(ParentProcessId)로 거꾸로 찾아도 소용없었다 — Git Bash
448
+ * 는 `bin/bash.exe → usr/bin/bash.exe → 손자` 로 한 겹이 더 있고, 가운데 것이 이미 끝나
449
+ * 사슬이 끊겨 있다(재 봄). 그래서 false 를 돌려주고, 부르는 쪽이 「못 껐다」 고 말한다.
450
+ *
451
+ * @returns {boolean} 끊으러 갈 길이 있었나
452
+ */
453
+ export function 남은무리끊기(kid) {
454
+ if (process.platform === 'win32' || !kid?.pid) return false;
455
+ 무리끊기(kid.pid);
456
+ return true;
457
+ }
458
+
323
459
  /**
324
460
  * 죽인 뒤, 남은 말이 다 나오기를 잠깐 기다린다.
325
461
  *
@@ -456,7 +592,17 @@ class 일감 {
456
592
  const r = decodeBytes(다, { fallback: 콘솔 });
457
593
  // 처음 한 번만 못 박는다. 아직 아무것도 안 쌓였을 때 정하면
458
594
  // 표본이 너무 적어 엉뚱하게 잡히므로, 실제로 글이 나온 뒤에 정한다.
459
- if (r.text) this.인코딩 = r.encoding;
595
+ /*
596
+ * ── ASCII 뿐인 동안은 못 박지 않는다 (사냥5 L1) ───────────────────────
597
+ *
598
+ * 「글이 나온 뒤」 를 글자가 있느냐로만 봤다. 그래서 dev 서버가 흔히 그러듯 영어 배너가
599
+ * 먼저 나오면 그 순간 utf-8 로 못 박혔고, 나중에 한글 윈도우 콘솔이 CP949 로 한글을
600
+ * 뱉자 **처음부터 다시 읽어도** 영영 깨졌다 — 통째로 새로 풀면 멀쩡한 바이트인데.
601
+ *
602
+ * ASCII(0x7F 이하)만 있으면 어느 인코딩으로 풀어도 같은 글자·같은 글자 수라, 안 못
603
+ * 박아도 읽은 자리(읽은글자)가 어긋나지 않는다. 판정에 쓸 바이트가 처음 나왔을 때 박는다.
604
+ */
605
+ if (r.text && 다.some((b) => b > 127)) this.인코딩 = r.encoding;
460
606
  this.푼것 = { 판: this.판, 글: r.text };
461
607
  return r.text;
462
608
  } catch { return 다.toString('utf8'); }
@@ -553,6 +699,9 @@ export async function 띄우기(명령, { cwd, 설명 = null, 기다림 = 1500,
553
699
  j.띄우기실패 = true;
554
700
  });
555
701
  kid.on('close', (code, sig) => {
702
+ // 파이프까지 다 닫혔다 — 셸만이 아니라 출력을 문 것이 하나도 안 남았다는 뜻이다.
703
+ // 끝내기 가 「진짜 끝났나」 를 이것으로 본다 (사냥5 M2 · 끝내기 머리말).
704
+ j.닫힘 = true;
556
705
  if (j.상태 === '죽임') return;
557
706
  j.상태 = '끝남';
558
707
  /*
@@ -608,6 +757,8 @@ export function 목록() {
608
757
  시그널: j.시그널,
609
758
  초: j.산햇수(),
610
759
  안읽은글자: Math.max(0, j.전체글().length - j.읽은글자),
760
+ // 셸은 끝났는데 파이프가 안 닫혔다 — 셸이 뒤로 띄운 것이 아직 돈다 (사냥5 M2).
761
+ 셸끝남: j.상태 === '도는중' && !!j.kid && !j.닫힘 && !아직사나(j.kid),
611
762
  }));
612
763
  }
613
764
 
@@ -631,7 +782,21 @@ export function 읽기(번호, opts) {
631
782
  export async function 끝내기(번호) {
632
783
  const j = 하나(번호);
633
784
  if (!j) return null;
634
- if (j.상태 !== '도는중') { 일감들.delete(j.번호); return { 이미: true, 명령: j.명령 }; }
785
+ // 갓 띄운 것이면 셸이 나무를 다 세울 틈을 준다 (아래 나무설틈). 그 사이 끝났으면 바로 아래 「이미 끝난 것」 으로 간다.
786
+ const 갓 = 나무설틈 - (Date.now() - j.띄운때);
787
+ if (j.상태 === '도는중' && 갓 > 0) await new Promise((r) => setTimeout(r, 갓));
788
+ /*
789
+ * 이미 끝난 것도 **안 읽은 출력은 건네고** 지운다 (사냥5 L2).
790
+ *
791
+ * 여기는 곧장 지웠다. 뜨고 조금 돌다 죽은 일감에 stop 을 주면, 죽기 직전 남긴 말 —
792
+ * 이 머리말이 「제일 중요한 몇 줄」 이라 부르는 그것 — 을 한 번도 안 보여 주고 버렸다.
793
+ * 지우고 나면 다시 읽을 길도 없다.
794
+ */
795
+ if (j.상태 !== '도는중') {
796
+ const 남은 = j.읽기({});
797
+ 일감들.delete(j.번호);
798
+ return { 이미: true, 명령: j.명령, 남은: 남은.글, 종료코드: j.종료코드, 시그널: j.시그널 };
799
+ }
635
800
  j.상태 = '죽임';
636
801
  /*
637
802
  * 사람이 시킨 자리라 넉넉히 기다린다.
@@ -641,8 +806,10 @@ export async function 끝내기(번호) {
641
806
  * 안 죽은 서버 하나가 훨씬 비싸다.
642
807
  */
643
808
  const 넉넉히 = 6000;
644
- 나무죽이기(j.kid, { 파이프끊기: false, 상한: 넉넉히 });
645
- await 죽는말기다리기(j.kid, 죽는말기다림);
809
+ // 여기는 async 라 **진짜로 기다릴 수 있다** (무리끊기기다려 머리말). 동기로
810
+ // 기다리면 유닉스에서는 죽은 자식이 거둬지지 않아 이 6초를 매번 다 쓴다.
811
+ await 나무죽이기기다려(j.kid, { 파이프끊기: false, 상한: 넉넉히 });
812
+ if (!j.닫힘) await 죽는말기다리기(j.kid, 죽는말기다림);
646
813
  /*
647
814
  * 아직 살아 있으면 여기서 끊는다.
648
815
  *
@@ -656,8 +823,21 @@ export async function 끝내기(번호) {
656
823
  * 윈도우에서는 아무 뜻이 없었다는 말이다. 여기는 마지막 자리라 뿌리라도
657
824
  * 죽여야 한다. (모두끝내기 는 이미 그렇게 하고 있었다.)
658
825
  */
659
- if (아직사나(j.kid)) {
660
- 나무죽이기(j.kid, { 파이프끊기: false, 곧장: true, 상한: 넉넉히, 뿌리살리기: false });
826
+ /*
827
+ * ── 셸은 죽었는데 **파이프가 안 닫힌** 판도 안 끝난 것이다 (사냥5 M2) ──────────
828
+ *
829
+ * 여기는 `아직사나(j.kid)` — 셸의 exitCode — 만 봤다. `node gc.cjs & echo done` 처럼
830
+ * 셸이 뒤로 띄운 손자가 출력을 물고 있으면, 셸은 이미 끝나 있어 taskkill /t 가 나무를
831
+ * 못 찾고(「그런 프로세스 없음」 = 이미 죽음으로 친다), 아래 죽었나 도 참이 되어
832
+ * 「끝냈습니다」 가 나갔다. 손자는 그대로 돌았다.
833
+ *
834
+ * 그래서 「끝났나」 를 'close'(닫힘)로 본다. 셸이 살아 있으면 예전처럼 곧장 끊고, 셸이
835
+ * 이미 없으면 남은 것을 끊으러 간다 — 유닉스는 무리가 우두머리 없이도 남아 있어 무리째
836
+ * 끊을 수 있다. 윈도우는 뿌리가 사라지면 그 아래를 가리킬 길이 없다(남은무리끊기 머리말).
837
+ */
838
+ if (!j.닫힘) {
839
+ if (아직사나(j.kid)) 나무죽이기(j.kid, { 파이프끊기: false, 곧장: true, 상한: 넉넉히, 뿌리살리기: false });
840
+ else 남은무리끊기(j.kid);
661
841
  /*
662
842
  * 죽인 **직후에 살았나 물으면 안 된다.**
663
843
  *
@@ -669,7 +849,11 @@ export async function 끝내기(번호) {
669
849
  * 전체에서만 빨개졌다 — 컴퓨터가 바쁘면 관찰이 늦기 때문이다.
670
850
  * 그러니 한 번 더 기다렸다가 묻는다.
671
851
  */
672
- await 죽는말기다리기(j.kid, 죽는말기다림);
852
+ if (!j.닫힘) await 죽는말기다리기(j.kid, 죽는말기다림);
853
+ // 셸은 끝났는데 아직 안 닫혔으면 바쁜 컴퓨터가 파이프를 비울 틈을 한 번 더 준다.
854
+ // 1.5초로는 모자랐다 — 검사 여덟 개를 한꺼번에 돌린 판에서 멀쩡히 죽은 자식을 「안 끝났습니다」
855
+ // 로 적었다. 이 기다림은 진짜로 손자가 파이프를 문 판에서만 끝까지 쓰인다.
856
+ if (!j.닫힘 && !아직사나(j.kid)) await 죽는말기다리기(j.kid, 3000);
673
857
  }
674
858
  /*
675
859
  * ── 안 죽은 놈의 파이프는 **끊지 않는다** ──────────────────────────────
@@ -680,7 +864,9 @@ export async function 끝내기(번호) {
680
864
  * 사람은 화면을 보며 기다리고, 자식은 다음 쓰기에서 EPIPE 를 맞는다.
681
865
  * 남겨 두기로 한 까닭이 「다시 볼 수 있게」 인데 정작 볼 길을 끊던 셈이다.
682
866
  */
683
- const 죽었나 = !아직사나(j.kid);
867
+ // 셸의 종료만이 아니라 파이프가 닫혀야 끝난 것이다 (위 「파이프가 안 닫힌 판」).
868
+ // 흉내 자식(검사)처럼 close 를 못 내는 것도 있어, 셸이 살아 있으면 여전히 안 끝난 것으로 본다.
869
+ const 죽었나 = j.닫힘 === true || (!아직사나(j.kid) && !j.kid?.stdout?.readable && !j.kid?.stderr?.readable);
684
870
  if (죽었나) 파이프끊기(j.kid);
685
871
  const 남은 = j.읽기({});
686
872
  /*
@@ -695,7 +881,8 @@ export async function 끝내기(번호) {
695
881
  */
696
882
  if (!죽었나) {
697
883
  j.상태 = '도는중'; // 지우지 않는다 — 다시 시킬 수 있어야 한다
698
- return { 이미: false, 안죽음: true, 명령: j.명령, 초: j.산햇수(), 남은: 남은.글 };
884
+ // 셸은 끝났는데 남은 것이 문 판이면 그렇다고 따로 말한다 — 할 수 있는 일이 다르다.
885
+ return { 이미: false, 안죽음: true, 뒤에남음: !아직사나(j.kid), 명령: j.명령, 초: j.산햇수(), 남은: 남은.글 };
699
886
  }
700
887
  일감들.delete(j.번호);
701
888
  return { 이미: false, 명령: j.명령, 초: j.산햇수(), 남은: 남은.글 };
@@ -709,6 +896,9 @@ export async function 끝내기(번호) {
709
896
  */
710
897
  export function 모두끝내기() {
711
898
  const n = [...일감들.values()].filter((j) => j.상태 === '도는중').length;
899
+ // 갓 띄운 것이 있으면 제일 어린 것이 나무설틈 만큼 살 때까지 한 번만 기다린다 — 끝나는 길이라 동기로.
900
+ const 제일갓 = Math.max(0, ...[...일감들.values()].filter((j) => j.상태 === '도는중').map((j) => 나무설틈 - (Date.now() - j.띄운때)));
901
+ if (제일갓 > 0) 동기로쉬기(제일갓);
712
902
  for (const j of 일감들.values()) {
713
903
  if (j.상태 !== '도는중') continue;
714
904
  j.상태 = '죽임';
@@ -763,24 +953,73 @@ const 인자이름 = {
763
953
  };
764
954
  const 아는이름 = new Set(Object.values(인자이름).flat());
765
955
 
956
+ /*
957
+ * ── 참·거짓을 **글자로** 보내는 모델 ───────────────────────────────────
958
+ *
959
+ * 규격에는 boolean 이라고 적어 뒀지만, 여기 오는 것은 모델이 지어낸 JSON 이다.
960
+ * 작은 로컬 모델은 `"stop": "false"` · `"no"` · `"0"` 처럼 글자로 보낸다.
961
+ * `!!'false'` 는 **참**이다 — 끄지 말라고 적어 보낸 부름이 도는 서버를 껐다.
962
+ * 위 머리말이 없애겠다고 적어 둔 「시킨 적 없는 일을 조용히 하는」 고장이,
963
+ * 이름(`action`)이 아니라 **값** 쪽에 그대로 남아 있던 자리다.
964
+ *
965
+ * 그래서 아는 꼴은 뜻대로 읽고, 모르는 꼴(`"아마도"`)은 **거짓으로 두고 말한다.**
966
+ * 모르는 값으로 끄는 쪽이 훨씬 나쁘다 — 끈 것은 못 되돌리고, 안 끈 것은
967
+ * 다음 걸음에서 고쳐 부르면 된다.
968
+ */
969
+ const 참인말 = new Set(['true', '1', 'yes', 'y', 'on', '참', '예', '네']);
970
+ const 거짓인말 = new Set(['false', '0', 'no', 'n', 'off', 'none', 'null', '거짓', '아니오', '아니요', '']);
971
+
972
+ /** @returns {{값: boolean, 모름?: string}} */
973
+ function 참거짓(v) {
974
+ if (v == null) return { 값: false };
975
+ if (typeof v === 'boolean') return { 값: v };
976
+ // 숫자는 0 만 거짓이다. NaN 은 「무엇을 보냈는지」 를 적어 주는 쪽으로 보낸다.
977
+ if (typeof v === 'number') return Number.isFinite(v) ? { 값: v !== 0 } : { 값: false, 모름: String(v) };
978
+ const s = String(v).trim().toLowerCase();
979
+ if (참인말.has(s)) return { 값: true };
980
+ if (거짓인말.has(s)) return { 값: false };
981
+ return { 값: false, 모름: String(v) };
982
+ }
983
+
766
984
  /**
767
985
  * 준 인자에서 아는 것만 골라낸다.
768
986
  *
769
- * @returns {{번호: number|null, 끝내기: boolean, 처음부터: boolean, 준것: string[], 모르는것: string[]}}
987
+ * `못읽은값` 은 `[받은 칸 이름, 받은 값]` 들이다 — 참·거짓 자리에 참도 거짓도
988
+ * 아닌 것이 왔을 때. 부르는 쪽이 **그 칸 이름 그대로** 적어 줘야 모델이 무엇을
989
+ * 고쳐 부를지 안다(`kill` 로 보냈는데 `stop` 을 받았다고 적으면 못 고친다).
990
+ *
991
+ * @returns {{날번호: any, 번호: number|null, 끝내기: boolean, 처음부터: boolean,
992
+ * 못읽은값: [string, string][], 준것: string[], 모르는것: string[]}}
770
993
  */
771
994
  export function 일감인자(args) {
772
995
  const 준것 = Object.keys(args ?? {});
773
996
  const 하나씩 = (이름) => {
774
- for (const n of 인자이름[이름]) if (args?.[n] != null) return args[n];
775
- return null;
997
+ for (const n of 인자이름[이름]) if (args?.[n] != null) return { 칸: n, 값: args[n] };
998
+ return { 칸: null, 값: null };
999
+ };
1000
+ const 날번호 = 하나씩('번호').값;
1001
+ const 못읽은값 = [];
1002
+ const 참거짓칸 = (이름) => {
1003
+ const { 칸, 값 } = 하나씩(이름);
1004
+ const r = 참거짓(값);
1005
+ if (r.모름 !== undefined) 못읽은값.push([칸 ?? 이름, r.모름]);
1006
+ return r.값;
776
1007
  };
777
- const 날번호 = 하나씩('번호');
778
1008
  return {
779
1009
  // 날것을 같이 내놓는다 — 숫자가 아닐 때 **무엇을 받았는지** 적어야 한다.
780
1010
  날번호,
781
- 번호: 날번호 == null ? null : Number(날번호),
782
- 끝내기: !!하나씩('끝내기'),
783
- 처음부터: !!하나씩('처음부터'),
1011
+ /*
1012
+ * 숫자도 숫자 글자도 아닌 것은 **숫자로 안 읽는다.** 날 `Number()` 였을 때
1013
+ * 자바스크립트가 `true` 를 1 로, `[]` 를 0 으로 조용히 바꿔서 `Jobs({job:true,
1014
+ * stop:true})` 가 1번 일감을 끊었다 — 모델이 가리킨 적도 없는 서버를. 위 참거짓()
1015
+ * 이 stop·처음부터 에 이미 세워 둔 문턱이고, 번호 칸만 그 밖에 있었다. NaN 으로
1016
+ * 두면 아래 「번호는 숫자여야 합니다 (받은 것: …)」 갈래가 받아 준다.
1017
+ */
1018
+ 번호: 날번호 == null ? null
1019
+ : (typeof 날번호 === 'number' || typeof 날번호 === 'string' ? Number(날번호) : NaN),
1020
+ 끝내기: 참거짓칸('끝내기'),
1021
+ 처음부터: 참거짓칸('처음부터'),
1022
+ 못읽은값,
784
1023
  준것,
785
1024
  모르는것: 준것.filter((k) => !아는이름.has(k)),
786
1025
  };
@@ -862,6 +1101,17 @@ export const JOBS_TOOL = {
862
1101
  ? `\n(못 알아들어 버린 인자: ${받은것.모르는것.join(', ')} — Jobs 는 job · stop · from_start 만 받습니다.`
863
1102
  + ' 끝내려던 것이면 stop: true 로 다시 불러라.)'
864
1103
  : '';
1104
+ /*
1105
+ * 이름은 아는데 **값**이 참도 거짓도 아닌 판. 같은 자세로 말해 준다.
1106
+ *
1107
+ * 끄지는 않는다(일감인자 머리말) — 모르는 값으로 끈 것은 못 되돌리고,
1108
+ * 안 끈 것은 다음 걸음에서 고쳐 부르면 된다. 대신 받은 칸 이름과 받은
1109
+ * 값을 그대로 적어, 뜻이 거기 있었으면 고쳐 부르게 한다.
1110
+ */
1111
+ const 못읽은말 = 받은것.못읽은값.length
1112
+ ? `\n(참·거짓으로 못 읽은 값: ${받은것.못읽은값.map(([칸, 값]) => `${칸}=${JSON.stringify(값)}`).join(', ')}`
1113
+ + ' — true·false 로 보내세요. 끝내려던 것이면 stop: true 로 다시 불러라.)'
1114
+ : '';
865
1115
  const 번호 = 받은것.번호;
866
1116
  // 숫자가 아닌 것을 받으면 NaN 이 되고, 그대로 두면 `NaN번 일감이 없습니다`
867
1117
  // 라는 말이 모델에게 나간다. 무엇이 잘못됐는지 안 알려 주는 오류다.
@@ -894,9 +1144,20 @@ export const JOBS_TOOL = {
894
1144
  };
895
1145
  }
896
1146
  const ls = 목록();
897
- if (!ls.length) return { content: '뒤에서 도는 명령이 없습니다.', summary: 세말('count', 0) };
1147
+ /*
1148
+ * 목록이 비어도 **하던 말은 한다.**
1149
+ *
1150
+ * 여기가 그 자리에서 곧장 돌아서느라 버린말·못읽은말 이 통째로 빠졌다.
1151
+ * 위 두 머리말이 「몇 개만 못 알아들은 판도 말해 준다」 고 적어 두었는데,
1152
+ * 하필 **일감이 하나도 없을 때**만 그 약속이 안 지켜졌다. 일감이 없다는
1153
+ * 것은 대개 「끄려던 것이 이미 없다」 거나 「아직 안 띄웠다」 인데, 그때
1154
+ * 오타를 안 알려 주면 모델은 제 인자가 맞는 줄 알고 그대로 다시 부른다.
1155
+ */
1156
+ if (!ls.length) return { content: '뒤에서 도는 명령이 없습니다.' + 버린말 + 못읽은말, summary: 세말('count', 0) };
898
1157
  const 줄들 = ls.map((j) => {
899
- const 상 = j.상태 === '도는중' ? `도는중 ${j.초}초` : j.시그널 ? `${j.시그널} 로 죽음` : `끝남 (종료코드 ${j.종료코드})`;
1158
+ const 상 = j.상태 === '도는중'
1159
+ ? `도는중 ${j.초}초${j.셸끝남 ? ' (셸은 끝남 — 셸이 뒤로 띄운 것이 아직 출력을 물고 돕니다)' : ''}`
1160
+ : j.시그널 ? `${j.시그널} 로 죽음` : `끝남 (종료코드 ${j.종료코드})`;
900
1161
  const 새것 = j.안읽은글자 ? ` · 안 읽은 출력 ${j.안읽은글자}자` : '';
901
1162
  return ` ${j.번호}. ${j.명령} — ${상}${새것}`;
902
1163
  });
@@ -908,13 +1169,20 @@ export const JOBS_TOOL = {
908
1169
  * 그러면 포트가 물려서 안 뜨고, 왜 안 되는지도 모른다.
909
1170
  */
910
1171
  if (치운것) 줄들.push(` (오래된 것 ${치운것}개는 자리를 위해 지웠습니다)`);
911
- return { content: 줄들.join('\n') + 버린말, summary: 세말('count', ls.length), 일감수: ls.length };
1172
+ return { content: 줄들.join('\n') + 버린말 + 못읽은말, summary: 세말('count', ls.length), 일감수: ls.length };
912
1173
  }
913
1174
 
914
1175
  if (받은것.끝내기) {
915
1176
  const r = await 끝내기(번호);
916
1177
  if (!r) return { error: `${번호}번 일감이 없습니다. 번호 없이 Jobs 를 불러 목록을 보세요.` };
917
- if (r.이미) return { content: `${번호}번은 이미 끝나 있었습니다: ${r.명령}`, summary: 말('sum.jobEndedAlready') };
1178
+ if (r.이미) {
1179
+ // 안 읽은 마지막 출력을 같이 준다 — 지우고 나면 다시 읽을 길이 없다 (끝내기 머리말 · 사냥5 L2).
1180
+ return {
1181
+ content: `${번호}번은 이미 끝나 있었습니다 (${끝난말(r)}): ${r.명령}`
1182
+ + (r.남은 ? `\n\n안 읽은 마지막 출력:\n${뒤만(r.남은, 2000).글}` : '') + 버린말 + 못읽은말,
1183
+ summary: 말('sum.jobEndedAlready'),
1184
+ };
1185
+ }
918
1186
  /*
919
1187
  * ★ 안 죽었으면 「끝냈습니다」 라고 하지 않는다.
920
1188
  *
@@ -923,16 +1191,26 @@ export const JOBS_TOOL = {
923
1191
  * 남겨 뒀으니 한 번 더 시킬 수 있다고 알려 준다.
924
1192
  */
925
1193
  if (r.안죽음) {
1194
+ /*
1195
+ * 셸은 끝났는데 셸이 뒤로 띄운 것이 남은 판은 따로 말한다 (사냥5 M2). 한 번 더 시켜도
1196
+ * 윈도우에서는 같은 결과라, 「다시 시켜 보세요」 만 적으면 헛걸음을 시킨다.
1197
+ */
1198
+ const 뒤에남은말 = !r.뒤에남음 ? '\n죽이라고 시켰는데 아직 살아 있습니다. 목록에 그대로 두었으니 한 번 더 시켜 보세요.'
1199
+ : process.platform === 'win32'
1200
+ ? '\n셸은 끝났지만 셸이 뒤로 띄운 프로세스가 아직 출력을 물고 돕니다. 윈도우에서는 셸이 먼저 끝나면'
1201
+ + ' 그 아래를 찾아 끌 길이 없습니다 — 작업 관리자에서 찾아 끄세요. 출력은 계속 읽을 수 있게 목록에 남겨 두었습니다.'
1202
+ : '\n셸은 끝났지만 셸이 뒤로 띄운 프로세스가 아직 출력을 물고 돕니다. 무리째 끊으라고 했는데 아직 살아 있습니다.'
1203
+ + ' 목록에 그대로 두었으니 한 번 더 시켜 보세요.';
926
1204
  return {
927
1205
  error: `${번호}번이 안 끝났습니다 (${r.초}초 돌았습니다): ${r.명령}`
928
- + '\n죽이라고 시켰는데 아직 살아 있습니다. 목록에 그대로 두었으니 한 번 더 시켜 보세요.'
1206
+ + 뒤에남은말
929
1207
  + (r.남은 ? `\n\n마지막 출력:\n${뒤만(r.남은, 2000).글}` : ''),
930
1208
  summary: 말('sum.jobNotStopped'),
931
1209
  };
932
1210
  }
933
1211
  return {
934
1212
  content: `${번호}번을 끝냈습니다 (${r.초}초 돌았습니다): ${r.명령}`
935
- + (r.남은 ? `\n\n마지막 출력:\n${뒤만(r.남은, 2000).글}` : '') + 버린말,
1213
+ + (r.남은 ? `\n\n마지막 출력:\n${뒤만(r.남은, 2000).글}` : '') + 버린말 + 못읽은말,
936
1214
  summary: 이어(말('sum.jobStopped'), 말('unit.sec', { n: r.초 })),
937
1215
  };
938
1216
  }
@@ -948,7 +1226,7 @@ export const JOBS_TOOL = {
948
1226
  ? (r.앞잘림 ? '(앞부분은 너무 길어 잘렸습니다 — 뒤쪽만 남았습니다)\n' : '') + 잘라낸.글
949
1227
  : '(지난번 읽은 뒤로 새 출력이 없습니다)';
950
1228
  return {
951
- content: `${머리}\n\n${몸}${버린말}`,
1229
+ content: `${머리}\n\n${몸}${버린말}${못읽은말}`,
952
1230
  summary: (r.상태 === '도는중' ? 이어(말('sum.jobRunning'), 말('unit.sec', { n: r.초 })) : 끝난말(r))
953
1231
  + (잘라낸.줄임 ? ' · 일부만' : ''),
954
1232
  // 끝난 일감이 종료코드 0 이 아니면 실패로 물들인다 — Bash 와 같은 규칙이다.