deel-local-cli 1.8.0 → 1.12.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 (61) hide show
  1. package/README.ko.md +10 -25
  2. package/README.md +43 -51
  3. package/bin/deel.js +56 -13
  4. package/package.json +60 -60
  5. package/src/acp/serve.js +810 -732
  6. package/src/agent/budget.js +156 -153
  7. package/src/agent/compact.js +420 -296
  8. package/src/agent/effort.js +308 -177
  9. package/src/agent/evidence.js +2 -0
  10. package/src/agent/evolve.js +263 -213
  11. package/src/agent/filemem.js +155 -0
  12. package/src/agent/grade.js +51 -1
  13. package/src/agent/loop.js +1707 -1349
  14. package/src/agent/memory.js +156 -152
  15. package/src/agent/mention.js +210 -210
  16. package/src/agent/session.js +1049 -832
  17. package/src/agent/store.js +186 -9
  18. package/src/agent/threads.js +26 -1
  19. package/src/backend/adapter.js +1159 -615
  20. package/src/backend/cachemark.js +150 -0
  21. package/src/backend/detect.js +327 -288
  22. package/src/backend/learn.js +24 -0
  23. package/src/backend/mcp.js +96 -5
  24. package/src/backend/probe.js +131 -61
  25. package/src/backend/quota.js +250 -133
  26. package/src/backend/retry.js +30 -6
  27. package/src/backend/tokens.js +37 -0
  28. package/src/backend/toolfit.js +352 -0
  29. package/src/backend/wire.js +600 -0
  30. package/src/commands.js +3135 -2918
  31. package/src/config.js +47 -2
  32. package/src/i18n/en.js +544 -466
  33. package/src/i18n/index.js +18 -0
  34. package/src/i18n/ja.js +497 -417
  35. package/src/i18n/ko.js +591 -466
  36. package/src/i18n/zh.js +497 -417
  37. package/src/lsp/client.js +49 -5
  38. package/src/oneshot.js +620 -471
  39. package/src/pack/sbom.js +30 -4
  40. package/src/pack/selfpack.js +25 -9
  41. package/src/pack/sheet.en.js +288 -0
  42. package/src/pack/tar.js +65 -2
  43. package/src/plugins/manage.js +46 -9
  44. package/src/providers/bedrock.js +17 -0
  45. package/src/repl.js +2602 -2286
  46. package/src/safety/audit.js +92 -8
  47. package/src/safety/authcmd.js +14 -3
  48. package/src/safety/guard.js +143 -0
  49. package/src/safety/keystore.js +62 -39
  50. package/src/safety/undo.js +24 -6
  51. package/src/tools/fsutil.js +265 -250
  52. package/src/tools/index.js +239 -34
  53. package/src/tools/jobs.js +158 -29
  54. package/src/tools/verify.js +358 -328
  55. package/src/tools/webfetch.js +92 -9
  56. package/src/ui/md.js +201 -5
  57. package/src/ui/motion.js +0 -1
  58. package/src/ui/pastechip.js +50 -3
  59. package/src/ui/pick.js +115 -0
  60. package/src/ui/screen.js +23 -3
  61. package/src/ui/status.js +613 -610
package/src/oneshot.js CHANGED
@@ -1,471 +1,620 @@
1
- // 한 번만 돌고 끝내는 비대화 모드. deel run "..." · echo "..." | deel run
2
- //
3
- // 왜 따로 냈는가:
4
- // 사내에 넣고 나면 곧바로 "배치에서 부르고 싶다" 는 말이 나온다. 야간 작업에
5
- // 끼워 검사를 돌리거나, 파일 목록을 훑어 한 건씩 시키는 식이다.
6
- // 그런데 대화 화면(repl.js)은 사람이 앞에 앉아 있다는 것을 전제로 만들었다 —
7
- // 줄을 기다리고, 승인을 물어보고, 엑셀 암호를 물어본다. 그 자리에 사람이
8
- // 없으면 프로그램은 터지지도 않고 그냥 서 있는다. 배치 잡의 시간 제한까지
9
- // 서 있다가 죽고, 로그에는 아무 단서도 안 남는다.
10
- //
11
- // 그래서 '묻는 자리' 를 전부 없앤 길을 따로 낸다. 에이전트 루프는 그대로 쓴다 —
12
- // 여기서 루프를 다시 짜면 두 벌이 되고, 언젠가 한쪽만 고쳐진다.
13
- import { c, mark, clip } from './ui/ansi.js';
14
- import { 규칙모으기, 정책읽기 } from './safety/policy.js';
15
- import { 받기설정 } from './safety/authcmd.js';
16
- import { run } from './agent/loop.js';
17
- import { Session } from './agent/session.js';
18
- import { makeScope } from './safety/guard.js';
19
- import { 모두끄기 as 언어서버다끄기 } from './lsp/client.js';
20
- import { History } from './safety/undo.js';
21
- import { Audit } from './safety/audit.js';
22
- import { activeProfile, load, resolveKey, 잠금소식 } from './config.js';
23
- import { 말 as 옮긴말 } from './i18n/index.js';
24
- import { 알림채움 } from './backend/retry.js';
25
- import { discover } from './skills/discover.js';
26
- import { allowEndpoint, setOffline } from './safety/network.js';
27
- import { 지금모드, 바깥인가, 나갈수있나 } from './safety/runmode.js';
28
- import { 주소가리기 } from './safety/secrets.js';
29
- import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
30
- import { route } from './agent/route.js';
31
- import { get as getWork } from './agent/modes.js';
32
- import { 모두끝내기 as 일감모두끝내기, 일감인자 } from './tools/jobs.js';
33
-
34
- /**
35
- * 종료코드.
36
- *
37
- * 스크립트는 화면 글이 아니라 이 숫자만 본다. 그래서 '끝났다' 와 '끝난 척했다'
38
- * 를 반드시 갈라 놔야 한다 — 걸음 수 상한에 걸려 멈춘 것을 0 으로 돌려주면
39
- * 야간 배치가 아무 일도 하고 초록불을 켠다. 그게 제일 나쁜 결말이다.
40
- */
41
- export const EXIT = {
42
- done: 0, // 끝까지 답했다
43
- error: 1, // 오류로 끝났다 (연결 없음 · 시킬 말 없음 · 모델 오류)
44
- limit: 2, // 도구 호출 걸음 수 상한에 닿았다
45
- stuck: 3, // 같은 자리에서 헛돌아 스스로 멈췄다
46
- aborted: 4, // 도중에 끊겼다 (Ctrl+C)
47
- };
48
-
49
- /**
50
- * 표준입력에 실려 말을 통째로 읽는다.
51
- *
52
- * 터미널이면 읽는다. 사람이 앉아 있는 자리에서 읽으려 들면 아무도 치는
53
- * 입력을 영영 기다린다 그게 바로 모드가 없애려는 상황이다.
54
- */
55
- async function 표준입력읽기() {
56
- if (process.stdin.isTTY) return '';
57
- const 조각 = [];
58
- try {
59
- for await (const b of process.stdin) 조각.push(b);
60
- } catch { /* 파이프가 먼저 닫히면 읽은 데까지만 쓴다 */ }
61
- return Buffer.concat(조각).toString('utf8');
62
- }
63
-
64
- // 도구 한 줄 요약. 화면 그림이 아니라 로그에 남을 글이라 색을 아낀다.
65
- function 도구줄(name, args) {
66
- const a = args ?? {};
67
- const 첫 = a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ?? a.purpose ?? a.목적 ??
68
- (a.command ? String(a.command).replace(/\s+/g, ' ') : null) ??
69
- // 뒤에서 도는 명령. 번호가 곧 그 일감의 이름이라, 이게 없으면 기록에
70
- // `Jobs()` 만 여러 줄 남아 나중에 무엇을 본 것인지 알 수 없다.
71
- // 이름 고르기는 jobs.js 한 군데에만 둔다 (영문 이름도 받는다).
72
- (() => { const g = 일감인자(a); return g.번호 != null ? `${g.번호}번${g.끝내기 ? ' · 끝내기' : ''}` : null; })() ??
73
- // 한 번에 여러 파일을 쓴 경우. 기록에 빈 괄호만 남으면 나중에 이 줄로는
74
- // 무엇을 만들었는지 없다 `deel run` 출력은 근거로 쓰인다.
75
- (Array.isArray(a.files) && a.files.length
76
- ? `${a.files[0]?.file_path ?? '?'}${a.files.length > 1 ? ` 외 ${a.files.length - 1}개` : ''}`
77
- : null) ??
78
- (Array.isArray(a.edits) && a.edits.length
79
- ? `${a.edits[0]?.file_path ?? '?'}${a.edits.length > 1 ? ` 외 ${a.edits.length - 1}군데` : ''}`
80
- : null) ??
81
- (Array.isArray(a.paths) && a.paths.length ? `${a.paths.length}개` : null) ??
82
- (Array.isArray(a.todos) ? `${a.todos.length}건` : '');
83
- const 안 = clip(String(첫 ?? ''), 48);
84
- return `${name}${안 ? `(${안})` : ''}`;
85
- }
86
-
87
- /**
88
- * 턴만 돌린다.
89
- *
90
- * @param {object} opts
91
- * prompt 시킬 말. 없으면 표준입력에서 읽는다
92
- * root/mode/work/think/effort/ctx/offline 대화 시작 옵션과 같은
93
- * yes 승인이 필요한 것도 그냥 실행 (기본은 거부)
94
- * json 결과를 JSON 덩이로
95
- * quiet 도구가 무엇을 했는지를 적는다 (오류는 그래도 적는다)
96
- * @returns {Promise<number>} 종료코드
97
- */
98
- export async function runOnce(opts = {}) {
99
- const json = opts.json === true;
100
- const quiet = opts.quiet === true;
101
-
102
- // 갈래로 나눠 쓴다.
103
- // 표준출력 모델의 마지막 답만. 그래야 > 파일 이나 | grep 이 그대로 먹는다.
104
- // 표준오류 도구가 무엇을 했는지. 사람이 볼 것이지 넘겨줄 것이 아니다.
105
- // 이걸 섞으면 파이프 뒤에 붙은 명령이 도구 기록까지 받아 먹는다.
106
- const 삐끗 = (s = '') => process.stderr.write(s + '\n');
107
- const 곁 = (s = '') => { if (!quiet) 삐끗(s); };
108
-
109
- const 내놓기 = (r) => {
110
- /*
111
- * 뒤에서 돌던 명령을 반드시 거둔다.
112
- *
113
- * 여기가 모드의 모든 끝맺음이 지나는 자리다. 배치는 이걸 빠뜨리면
114
- * 제일 크게 다친다 — 잡이 끝났다고 표시된 뒤에도 dev 서버가 계속 돌고,
115
- * 다음 잡이 같은 포트를 잡으려다 실패한다. 그 원인은 로그 어디에도 없다.
116
- */
117
- const 껐다 = 일감모두끝내기();
118
- if (껐다) 곁(` ${mark.ok} ${c.gray(`뒤에서 돌던 명령 ${껐다}개를 껐습니다.`)}`);
119
- // 고친 뒤 진단 때문에 뒤에서 데워진 언어 서버도 같이 거둔다.
120
- // 이건 아무 없이 한다 — 사용자가 띄우라고 한 적이 없는 것이라,
121
- // 껐다는 말부터 하면 "그건 또 뭐냐" 가 된다.
122
- 언어서버다끄기().catch(() => {});
123
- if (json) process.stdout.write(JSON.stringify(r) + '\n');
124
- else if (r.text) process.stdout.write(r.text.endsWith('\n') ? r.text : r.text + '\n');
125
- return r.code;
126
- };
127
- // 시작도 실패. --quiet 여도 이유는 반드시 적는다 — 스크립트를 고칠 사람이 볼 유일한 글이다.
128
- const 못함 = (reason, message) => {
129
- 삐끗(` ${c.red('')} ${message}`);
130
- return 내놓기({
131
- ok: false, reason, code: EXIT.error, text: '', why: message,
132
- tools: 0, steps: 0, usage: { in: 0, out: 0, calls: 0, ms: 0, retries: 0 }, ms: 0,
133
- });
134
- };
135
-
136
- // ── 시킬 ───────────────────────────────────────────────────────────
137
- // 인자로 것이 먼저다. 없을 때만 표준입력을 읽는다
138
- // 둘 다 있을 때 무엇이 이기는지가 헷갈리면 스크립트가 조용히 엉뚱한 일을 한다.
139
- let 시킬말 = String(opts.prompt ?? '').trim();
140
- if (!시킬말) 시킬말 = (await 표준입력읽기()).trim();
141
- if (!시킬말) {
142
- return 못함('no-prompt', '무엇을 시킬지 적어 주세요deel run "..." 또는 echo "..." | deel run');
143
- }
144
-
145
- const cfg = load();
146
- const prof = activeProfile(cfg);
147
- if (!prof) return 못함('no-config', '저장된 연결이 없습니다. deel setup 을 먼저 실행하세요.');
148
-
149
- // 열쇠를 방금 잠갔으면 알린다. 여기서는 stdout 쓴다
150
- // 배치로 부르는 쪽이 stdout JSON 으로 읽고 있을 수 있다.
151
- const 잠금 = 잠금소식();
152
- if (잠금) { try { process.stderr.write(` ${잠금}\n`); } catch { /* 못 써도 그만 */ } }
153
-
154
- const root = opts.root ? String(opts.root) : process.cwd();
155
- const conn = {
156
- kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
157
- key: resolveKey(prof), model: prof.model,
158
- /*
159
- * 열쇠를 갖고 있는 대신 **받아 오는** 설정 (safety/authcmd.js).
160
- *
161
- * 여기서는 설정만 싣는다. 명령은 요청 직전에 부른다 — 켤 때 한 번
162
- * 받아 두면 시간짜리 토큰이 세 시간짜리 대화 한복판에서 죽고,
163
- * 401 은 화면에서 「열쇠가 틀렸다」 와 구별이 안 된다.
164
- */
165
- 열쇠받기: 받기설정(prof, { 정책값: 정책읽기().값 }),
166
- ctx: opts.ctx ?? prof.ctx ?? CTX_DEFAULT,
167
- // 길이 상한 — deel --max-tokens 32k 로 높일 수 있다(대화 화면의 /out 과 같은 값).
168
- maxTokens: opts.maxTokens ?? prof.maxTokens ?? null,
169
- streaming: prof.streaming ?? false,
170
- tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
171
- vision: prof.vision ?? false,
172
- };
173
-
174
- /*
175
- * 자물쇠는 대화 화면과 똑같이 건다. 비대화라고 느슨해질 이유가 없다 —
176
- * 오히려 배치는 아무도 보는 자리라 더 단단해야 한다.
177
- *
178
- * 다른 것은 하나, **물을 수가 없다**는 것이다. 파이프 뒤에는 답할 사람이
179
- * 없다. 그래서 여기서는 묻는 대신 멈추고, 무엇을 붙이면 되는지를 말한다.
180
- * 조용히 나가 버리면 스크립트가 바깥으로 나가고 있는 줄 아무도 모른다.
181
- */
182
- const 바깥연결 = 바깥인가(conn.base);
183
- const 실행모드 = 지금모드({
184
- online: opts.online === true,
185
- // 관리 정책이 offline 못박아 뒀으면 옵션과 상관없이 켠다 (safety/policy.js).
186
- offline: !!(opts.offline ?? prof.offline ?? cfg?.offline),
187
- });
188
- const 나감 = 나갈수있나(실행모드, { 바깥: 바깥연결, 허가: prof.online === true });
189
- if (나감.물어볼까) {
190
- const 어디 = (() => { try { return new URL(conn.base).host; } catch { return conn.base; } })();
191
- return 못함('needs-online',
192
- `이 연결은 컴퓨터 밖으로 나갑니다 (${주소가리기(어디)}). 여기서는 물어볼 없습니다.\n`
193
- + ' 나가도 된다면 --online 을 붙이거나, 대화 화면(deel)에서 한 번 허락해 두세요.\n'
194
- + ' 이 컴퓨터 안의 모델을 쓰려면 deel scan --save 로 붙이세요.');
195
- }
196
- allowEndpoint(conn.base);
197
- if (실행모드.허가무시) setOffline(true);
198
-
199
- const session = new Session(conn, {
200
- root,
201
- mode: opts.mode ?? 'auto',
202
- work: opts.work ?? null,
203
- think: opts.think ?? 'medium',
204
- effort: opts.effort ?? 'save',
205
- maxSteps: opts.maxSteps ?? null, // null 이면 작업 모드가 정한다
206
- });
207
- // 지금 어느 실행 모드인가. 화면이 줄에 이걸 그린다(ui/status.js).
208
- // session 실어 두는 까닭은, 대화 도중 /model 로 옮겨도 같은 자리를 보게 하려는 것이다.
209
- session.실행모드 = 실행모드;
210
-
211
- const found = discover(root);
212
- session.skills = found.skills;
213
- session.commands = found.commands;
214
- session.plugins = found.plugins;
215
-
216
- // ── 물어보는 자리를 전부 막는다 ────────────────────────────────────────
217
- //
218
- // 여기가 파일의 핵심이다. 물어보는 함수 하나라도 기다리게 두면 그것 하나로
219
- // 배치가 선다. 그래서 세 자리 모두 '기다리지 않고 바로 답을 내는' 함수로 채운다.
220
- //
221
- // 승인은 기본이 거부다. 반대로 하면 안 된다 — 아무도 안 보는 자리에서
222
- // 되돌릴 없는 명령이 조용히 돌아가는 것이 프로그램이 제일 피하려는 일이다.
223
- // 정말 맡기고 싶은 사람은 --yes 로 그 뜻을 명시한다.
224
- const 승인필요 = session.mode !== 'auto';
225
- const 자동승인 = opts.yes === true;
226
- const ctx = {
227
- scope: makeScope(root),
228
- // 도구가 한 번에 돌려줄 양을 이 값에서 뽑는다 (agent/budget.js).
229
- // /model 로 갈아타면 conn 이 통째로 바뀌므로 그때마다 다시 읽는다.
230
- get 모델컨텍스트() { return conn.ctx ?? null; },
231
- // 모델이 그림을 수 있나 — Read 가 그림을 만났을 때 무슨 말을 할지가 여기서 갈린다.
232
- get 눈있나() { return !!conn.vision; },
233
- // 적어 둔 허락·금지 규칙 (safety/policy.js). 승인 모드보다 먼저 본다.
234
- 규칙들: 규칙모으기(cfg),
235
- history: new History(root),
236
- audit: new Audit(root),
237
- seen: new Set(),
238
- skills: found.skills,
239
- loadedSkills: new Set(),
240
- // 고친 진단을 볼지 (lsp/diag.js). 아래 내놓기() 에서 반드시 거둔다
241
- // 거두면 배치가 끝나고도 언어 서버가 폴더를 물고 남는다.
242
- lsp: { 켬: true },
243
- // 되물을 사람이 없으니 기본값을 그대로 돌려준다.
244
- ask: async (_label, o = {}) => o?.def ?? '',
245
- // 엑셀 암호를 여기서 기다리면 그대로 선다. 없다고 바로 답한다 —
246
- // 도구는 null 받으면 '암호가 걸려 읽었다' 정확히 말한다.
247
- askPassword: async () => null,
248
- confirm: async (name, args) => {
249
- const 무엇 = 도구줄(name, args);
250
- if (자동승인) {
251
- 곁(` ${c.yellow('!')} ${c.gray(`--yes 라서 묻지 않고 실행합니다 — ${무엇}`)}`);
252
- return true;
253
- }
254
- 곁(` ${c.yellow('⊘')} ${c.gray(`승인이 필요해 거부했습니다 (물어볼 사람이 없습니다) — ${무엇}`)}`);
255
- return false;
256
- },
257
- };
258
-
259
- // 거부당할 것을 모델에게 미리 알려 준다.
260
- //
261
- // 거부만 하고 이유를 안 알리면 모델은 같은 호출을 몇 번이고 다시 한다.
262
- // 사람이 '안 돼요' 라고 알고, 다시 물어보면 이번엔 된다고 믿는다.
263
- // 그러면 걸음 수만 쓰고 아무것도 끝난다.
264
- const 보낼글 = (승인필요 && !자동승인)
265
- ? `${시킬말}\n\n(비대화 모드다. 사람이 없어 승인을 물어볼 없고, 승인이 필요한 도구 호출은 자동으로 거부된다.`
266
- + ' 승인 없이 되는 방법을 골라라. 그래도 되면 무엇이 막혔는지 말로 알려라.)'
267
- : 시킬말;
268
-
269
- // ── 컨텍스트 길이 ─────────────────────────────────────────────────────
270
- //
271
- // 대화 화면과 같은 이유로 서버에 물어본다. 저장된 값을 믿으면 조용히 작아진다.
272
- // 다만 알아낸 값을 설정 파일에 도로 적지는 않는다 — 배치는 같은 명령을 여러 개
273
- // 동시에 띄우는 자리라, 그때마다 config.json 덮어쓰면 서로 밟는다.
274
- // 이번 번만 쓰고 버린다.
275
- if (opts.ctx == null) {
276
- try {
277
- const r = await probeCtx(conn, { timeout: 6000 });
278
- if (r?.value) {
279
- const = conn.ctx;
280
- conn.ctx = r.value;
281
- if (전 !== r.value) {
282
- 곁(` ${mark.ok} ${c.gray(`컨텍스트를 ${전.toLocaleString()} → ${r.value.toLocaleString()} 로 맞췄습니다 (${r.source ?? '서버'}에서 읽음)`)}`);
283
- }
284
- }
285
- } catch { /* 물어보면 설정값 그대로 간다. 여기서 멈출 일은 아니다 */ }
286
- }
287
-
288
- // 종합 모드면 한마디를 보고 알맞은 작업 모드로 옮긴다. 대화 화면과 같다.
289
- session.routed = null;
290
- if (session.work === 'auto') {
291
- const 골라진 = route(시킬말);
292
- if (골라진.mode) {
293
- session.routed = 골라진.mode;
294
- const w = getWork(골라진.mode);
295
- 곁(` ${c.hcyan(w.glyph)} ${c.gray(`${w.name} (${w.en}) 속에 ${골라진.why} 가 있어서`)}`);
296
- }
297
- }
298
-
299
- // ── 한 턴 ─────────────────────────────────────────────────────────────
300
- const turn = new AbortController();
301
- const 끊김 = () => { if (!turn.signal.aborted) turn.abort(); };
302
- process.on('SIGINT', 끊김);
303
-
304
- const t0 = Date.now();
305
- let reason = 'done';
306
- let why = '';
307
- let tools = 0;
308
- let steps = 0;
309
- let 이번단계글 = ''; // 지금 단계에서 모델이 흘린 글. 단계가 바뀌면 비운다
310
- let = null;
311
-
312
- try {
313
- // 하위 작업 안쪽에서 온 이벤트는 그 겹만큼 들여 쓴다.
314
- const 안쪽 = (ev) => (ev.depth ? ' '.repeat(ev.depth) : '');
315
-
316
- for await (const ev of run(session, ctx, 보낼글, { signal: turn.signal })) {
317
- switch (ev.type) {
318
- case 'stage':
319
- steps = ev.step;
320
- 이번단계글 = '';
321
- break;
322
-
323
- // 중간 단계의 말은 표준출력에 안 싣는다. "이제 파일을 읽어보겠습니다" 까지
324
- // 딸려 나가면 파이프 뒤에서 답만 골라 쓸 수가 없다.
325
- case 'content':
326
- 이번단계글 += ev.text;
327
- break;
328
-
329
- case 'tool': {
330
- tools++;
331
- /*
332
- * 실패는 실패로 보여야 한다.
333
- *
334
- * 전에는 `error` 빨갛게 칠했다. 그런데 탈이 났는데 error 는 없는
335
- * 도구가 있다 — Bash 는 종료코드 3 을, Verify 는 `탈 2개` 를 요약에
336
- * 담아 돌려준다. 그것들이 회색으로 찍히면, 나중에 이 기록을 읽는
337
- * 사람 눈에는 성공과 구별되지 않는다. `deel run` 의 출력은 곧
338
- * 근거로 쓰이는 물건이라 그 구별이 사라지면 안 된다.
339
- */
340
- const 결과 = ev.result?.error
341
- ? c.red(clip(String(ev.result.error).split('\n')[0], 70))
342
- : ev.result?.failed
343
- ? c.yellow(clip(ev.result?.summary ?? '실패', 70))
344
- : c.gray(clip(ev.result?.summary ?? '완료', 70));
345
- // 하위 작업 안쪽이면 들여 그린다. 그러면 하위가 만진 파일이
346
- // 부모가 만진 것과 똑같이 찍혀서, 기록으로 읽을 때 구분이 안 된다.
347
- 곁(`${안쪽(ev)} ${c.cyan('⏺')} ${c.bold(도구줄(ev.name, ev.args))} ${결과}`);
348
- /*
349
- * 비밀이 지나갔으면 여기서도 적는다.
350
- *
351
- * 배치는 아무도 안 보는 자리이고, 이 기록이 나중에 근거가 된다.
352
- * "열쇠가 대화에 들어갔다" 를 그 기록에서 빼면 안 된다 — 나중에
353
- * 사고가 났을 언제 새어 나갔는지 방법이 없어진다.
354
- */
355
- if (ev.비밀) {
356
- 삐끗(`${안쪽(ev)} ${ev.비밀.가렸나 ? c.gray('⊘') : c.yellow('!')} `
357
- + `${c.gray(`${ev.비밀.말}${ev.비밀.가렸나 ? ' — 모델에 가려서 보냈습니다' : ' — 파일 내용은 가리지 않습니다'}`)}`);
358
- }
359
- break;
360
- }
361
-
362
- /*
363
- * 하위 작업의 여닫는 줄.
364
- *
365
- * `deel run` 은 잡·CI 에서 돌고 그 기록이 나중에 근거가 된다. 하위가
366
- * 무엇을 맡았고 끝냈는지 안 남으면, 파일 넷이 어디서 나왔는지를
367
- * 기록만 보고는 알 수 없다.
368
- */
369
- case 'task_start':
370
- 곁(`${안쪽(ev)} ${c.magenta('⌥')} ${c.bold('하위 작업')} ${clip(ev.목적, 60)}`
371
- + ` ${c.gray(`따로 떨어진 대화 · 최대 ${ev.steps}걸음`)}`);
372
- break;
373
-
374
- case 'task_done': {
375
- const = ev.끝 ?? {};
376
- const 잘됨 = 끝.type === 'done';
377
- const 셈 = (끝.files ?? []).filter((f) => !f.dir && !f.missing).length;
378
- 곁(`${안쪽(ev)} ${잘됨 ? c.green('✓') : c.yellow('⚠')} ${c.gray('하위 작업')} ${clip(ev.목적, 50)}`
379
- + ` ${c.gray(잘됨 ? `끝냄 · 파일 ${셈}개 · ${끝.steps ?? 0}걸음` : `다 못 했습니다 (${끝.type}) · ${끝.steps ?? 0}걸음`)}`);
380
- break;
381
- }
382
-
383
- case 'retry':
384
- 곁(` ${c.yellow('↻')} ${c.gray(`${ev.why} 상한을 ${ev.from} ${ev.to} 올려 다시 부릅니다`)}`);
385
- break;
386
-
387
- // 서버가 잠깐 막아서 기다렸다 다시 부른 자리 (backend/retry.js).
388
- // 배치 기록에 남아야 "그날 밤 왜 12분이 걸렸나" 를 나중에 읽을 수 있다.
389
- case 'backoff':
390
- 곁(` ${c.yellow('↻')} ${c.gray(옮긴말('loop.backoff', 알림채움(ev)))}`);
391
- break;
392
-
393
- case 'folded':
394
- 곁(` ${c.cyan('◲')} ${c.gray(`오래된 도구 결과 ${ev.접은것}개를 접었습니다 (${ev.아낀토큰.toLocaleString()} 토큰을 비움)`)}`);
395
- break;
396
-
397
- case 'images_folded':
398
- 곁(` ${c.cyan('◲')} ${c.gray(`오래된 그림 ${(ev.뺀것들 ?? []).reduce((a3, x) => a3 + x.장수, 0)}장을 뺐습니다`)}`);
399
- break;
400
-
401
- case 'compacted':
402
- (` ${c.cyan('◱')} ${c.gray(`대화 ${ev.folded}개를 요약으로 접었습니다 (${ev.before.toLocaleString()} ${ev.after.toLocaleString()} 토큰)`)}`);
403
- break;
404
-
405
- case 'compact_failed':
406
- 곁(` ${c.gray(`(접지 못했습니다: ${ev.why})`)}`);
407
- break;
408
-
409
- case 'limit':
410
- reason = 'limit';
411
- why = `도구 호출 ${ev.steps}회에서 멈췄습니다. 번에 하기엔 일입니다 나눠서 시키세요.`;
412
- break;
413
-
414
- case 'stuck':
415
- reason = 'stuck';
416
- why = String(ev.why ?? '같은 자리에서 헛돌고 있어 멈췄습니다');
417
- break;
418
-
419
- case 'aborted':
420
- reason = 'aborted';
421
- why = '중단했습니다';
422
- break;
423
-
424
- case 'error':
425
- reason = 'error';
426
- why = String(ev.text ?? '알 수 없는 오류');
427
- break;
428
-
429
- case 'done':
430
- reason = 'done';
431
- 답 = ev.text ?? 이번단계글;
432
- break;
433
- }
434
- }
435
- } catch (err) {
436
- reason = 'error';
437
- why = String(err?.message ?? err);
438
- } finally {
439
- process.removeListener('SIGINT', 끊김);
440
- }
441
-
442
- // 끝까지 못 갔어도 여기까지 나온 말은 내준다. 빈손으로 돌려보내면
443
- // 됐는지 짐작할 거리조차 없다.
444
- if (답 == null) 답 = 이번단계글;
445
-
446
- const code = EXIT[reason] ?? EXIT.error;
447
- if (why) 삐끗(` ${reason === 'done' ? c.gray('·') : c.red('✗')} ${why}`);
448
- if (!json && !quiet) {
449
- const 조각 = [`${((Date.now() - t0) / 1000).toFixed(1)}초`];
450
- if (tools) 조각.push(`도구 ${tools}회`);
451
- 조각.push(`↑${session.usage.in.toLocaleString()} ↓${session.usage.out.toLocaleString()}`);
452
- 곁(` ${c.gray('── ' + 조각.join(' · '))}`);
453
- }
454
-
455
- return 내놓기({
456
- ok: reason === 'done',
457
- reason,
458
- code,
459
- text: String(답 ?? ''),
460
- tools,
461
- steps,
462
- usage: {
463
- in: session.usage.in, out: session.usage.out,
464
- calls: session.usage.calls, ms: session.usage.ms,
465
- retries: session.usage.retries ?? 0,
466
- },
467
- model: conn.model,
468
- ms: Date.now() - t0,
469
- ...(why ? { why } : {}),
470
- });
471
- }
1
+ // 한 번만 돌고 끝내는 비대화 모드. deel run "..." · echo "..." | deel run
2
+ //
3
+ // 왜 따로 냈는가:
4
+ // 사내에 넣고 나면 곧바로 "배치에서 부르고 싶다" 는 말이 나온다. 야간 작업에
5
+ // 끼워 검사를 돌리거나, 파일 목록을 훑어 한 건씩 시키는 식이다.
6
+ // 그런데 대화 화면(repl.js)은 사람이 앞에 앉아 있다는 것을 전제로 만들었다 —
7
+ // 줄을 기다리고, 승인을 물어보고, 엑셀 암호를 물어본다. 그 자리에 사람이
8
+ // 없으면 프로그램은 터지지도 않고 그냥 서 있는다. 배치 잡의 시간 제한까지
9
+ // 서 있다가 죽고, 로그에는 아무 단서도 안 남는다.
10
+ //
11
+ // 그래서 '묻는 자리' 를 전부 없앤 길을 따로 낸다. 에이전트 루프는 그대로 쓴다 —
12
+ // 여기서 루프를 다시 짜면 두 벌이 되고, 언젠가 한쪽만 고쳐진다.
13
+ import { c, mark, clip } from './ui/ansi.js';
14
+ import { 규칙모으기, 정책읽기 } from './safety/policy.js';
15
+ import { 받기설정 } from './safety/authcmd.js';
16
+ import { run } from './agent/loop.js';
17
+ import { Session } from './agent/session.js';
18
+ import { makeScope } from './safety/guard.js';
19
+ import { 모두끄기 as 언어서버다끄기 } from './lsp/client.js';
20
+ import { History } from './safety/undo.js';
21
+ import { Audit, 열쇠묻기 } from './safety/audit.js';
22
+ import { activeProfile, load, resolveKey, 잠금소식, 열쇠탈소식 } from './config.js';
23
+ import { 말 as 옮긴말 } from './i18n/index.js';
24
+ import { 알림채움 } from './backend/retry.js';
25
+ import { 전선붙이기, 세션이름짓기 } from './backend/wire.js';
26
+ import { newId } from './agent/store.js';
27
+ import { discover } from './skills/discover.js';
28
+ import { allowEndpoint, setOffline } from './safety/network.js';
29
+ import { 지금모드, 바깥인가, 나갈수있나 } from './safety/runmode.js';
30
+ import { 주소가리기 } from './safety/secrets.js';
31
+ import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
32
+ import { route } from './agent/route.js';
33
+ import { get as getWork } from './agent/modes.js';
34
+ import { 모두끝내기 as 일감모두끝내기, 일감인자 } from './tools/jobs.js';
35
+
36
+ /**
37
+ * 종료코드.
38
+ *
39
+ * 스크립트는 화면 글이 아니라 숫자만 본다. 그래서 '끝났다' '끝난 척했다'
40
+ * 를 반드시 갈라 놔야 한다 — 걸음 수 상한에 걸려 멈춘 것을 0 으로 돌려주면
41
+ * 야간 배치가 아무 일도 안 하고 초록불을 켠다. 그게 제일 나쁜 결말이다.
42
+ */
43
+ export const EXIT = {
44
+ done: 0, // 끝까지 답했다
45
+ error: 1, // 오류로 끝났다 (연결 없음 · 시킬 말 없음 · 모델 오류)
46
+ limit: 2, // 도구 호출 걸음 수 상한에 닿았다
47
+ stuck: 3, // 같은 자리에서 헛돌아 스스로 멈췄다
48
+ aborted: 4, // 도중에 끊겼다 (Ctrl+C)
49
+ /*
50
+ * 서버가 끝났다는 말을 번도 안 주고 답을 멈췄다.
51
+ *
52
+ * 여기에 자기 코드가 있어야 한다. 전에는 이걸 아예 보고 `done`(0) 으로
53
+ * 끝냈다. `deel -p` 잡·CI 에서 돌고 뒤에 스크립트가 붙는데, 반쪽짜리
54
+ * 답이 0 으로 넘어가면 그 스크립트는 온전한 답으로 알고 그대로 쓴다.
55
+ * 사람이 나중에 결과를 보고 왜 반쪽인지 되짚을 방법이 없다.
56
+ */
57
+ cutoff: 5,
58
+ /*
59
+ * 모델이 하겠다고 했다 (안전 판정).
60
+ *
61
+ * 이것도 자기 코드가 있어야 한다. 거절한 답은 **아무 일도 안 한 답**인데,
62
+ * 도구 호출이 없다는 것 말고는 짧은 답과 겉모습이 같다. 0 으로 끝내면
63
+ * 뒤에 붙은 스크립트는 일이 된 줄 알고 그 다음 단계로 넘어간다.
64
+ *
65
+ * 오류(1)와도 갈라 둔다. 고칠 자리가 다르기 때문이다 — 오류는 연결이나
66
+ * 열쇠를 보는 일이고, 거절은 **시킨 말을 바꾸는 일**이다.
67
+ */
68
+ refusal: 6,
69
+ };
70
+
71
+ /**
72
+ * 표준입력에 실려 말을 통째로 읽는다.
73
+ *
74
+ * 터미널이면 읽는다. 사람이 앉아 있는 자리에서 읽으려 들면 아무도 치는
75
+ * 입력을 영영 기다린다 — 그게 바로 이 모드가 없애려는 상황이다.
76
+ */
77
+ async function 표준입력읽기() {
78
+ if (process.stdin.isTTY) return '';
79
+ const 조각 = [];
80
+ try {
81
+ for await (const b of process.stdin) 조각.push(b);
82
+ } catch { /* 파이프가 먼저 닫히면 읽은 데까지만 쓴다 */ }
83
+ return Buffer.concat(조각).toString('utf8');
84
+ }
85
+
86
+ // 도구 한 줄 요약. 화면 그림이 아니라 로그에 남을 글이라 색을 아낀다.
87
+ function 도구줄(name, args) {
88
+ const a = args ?? {};
89
+ const 첫 = a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ?? a.purpose ?? a.목적 ??
90
+ (a.command ? String(a.command).replace(/\s+/g, ' ') : null) ??
91
+ // 뒤에서 도는 명령. 번호가 곧 그 일감의 이름이라, 이게 없으면 기록에
92
+ // `Jobs()` 여러 남아 나중에 무엇을 본 것인지 알 수 없다.
93
+ // 이름 고르기는 jobs.js 군데에만 둔다 (영문 이름도 받는다).
94
+ (() => { const g = 일감인자(a); return g.번호 != null ? `${g.번호}번${g.끝내기 ? ' · 끝내기' : ''}` : null; })() ??
95
+ // 번에 여러 파일을 경우. 기록에 빈 괄호만 남으면 나중에 이 줄로는
96
+ // 무엇을 만들었는지 수 없다 — `deel run` 의 출력은 곧 근거로 쓰인다.
97
+ (Array.isArray(a.files) && a.files.length
98
+ ? `${a.files[0]?.file_path ?? '?'}${a.files.length > 1 ? ` 외 ${a.files.length - 1}개` : ''}`
99
+ : null) ??
100
+ (Array.isArray(a.edits) && a.edits.length
101
+ ? `${a.edits[0]?.file_path ?? '?'}${a.edits.length > 1 ? ` 외 ${a.edits.length - 1}군데` : ''}`
102
+ : null) ??
103
+ (Array.isArray(a.paths) && a.paths.length ? `${a.paths.length}개` : null) ??
104
+ (Array.isArray(a.todos) ? `${a.todos.length}건` : '');
105
+ const = clip(String(첫 ?? ''), 48);
106
+ return `${name}${안 ? `(${안})` : ''}`;
107
+ }
108
+
109
+ /**
110
+ * 한 턴만 돌린다.
111
+ *
112
+ * @param {object} opts
113
+ * prompt 시킬 말. 없으면 표준입력에서 읽는다
114
+ * root/mode/work/think/effort/ctx/offline 대화 시작 옵션과 같은
115
+ * yes 승인이 필요한 것도 그냥 실행 (기본은 거부)
116
+ * json 결과를 JSON 한 덩이로
117
+ * quiet 도구가 무엇을 했는지를 안 적는다 (오류는 그래도 적는다)
118
+ * @returns {Promise<number>} 종료코드
119
+ */
120
+ export async function runOnce(opts = {}) {
121
+ const json = opts.json === true;
122
+ const quiet = opts.quiet === true;
123
+
124
+ // 갈래로 나눠 쓴다.
125
+ // 표준출력 — 모델의 마지막 답만. 그래야 > 파일 이나 | grep 이 그대로 먹는다.
126
+ // 표준오류 — 도구가 무엇을 했는지. 사람이 볼 것이지 넘겨줄 것이 아니다.
127
+ // 이걸 섞으면 파이프 뒤에 붙은 명령이 도구 기록까지 받아 먹는다.
128
+ const 삐끗 = (s = '') => process.stderr.write(s + '\n');
129
+ const 곁 = (s = '') => { if (!quiet) 삐끗(s); };
130
+
131
+ const 내놓기 = (r) => {
132
+ /*
133
+ * 뒤에서 돌던 명령을 반드시 거둔다.
134
+ *
135
+ * 여기가 이 모드의 모든 끝맺음이 지나는 자리다. 배치는 이걸 빠뜨리면
136
+ * 제일 크게 다친다 — 잡이 끝났다고 표시된 뒤에도 dev 서버가 계속 돌고,
137
+ * 다음 잡이 같은 포트를 잡으려다 실패한다. 원인은 로그 어디에도 없다.
138
+ */
139
+ const 껐다 = 일감모두끝내기();
140
+ if (껐다) 곁(` ${mark.ok} ${c.gray(`뒤에서 돌던 명령 ${껐다}개를 껐습니다.`)}`);
141
+ // 고친 뒤 진단 때문에 뒤에서 데워진 언어 서버도 같이 거둔다.
142
+ // 이건 아무 없이 한다사용자가 띄우라고 적이 없는 것이라,
143
+ // 껐다는 말부터 하면 "그건 또 뭐냐" 가 된다.
144
+ 언어서버다끄기().catch(() => {});
145
+ if (json) process.stdout.write(JSON.stringify(r) + '\n');
146
+ else if (r.text) process.stdout.write(r.text.endsWith('\n') ? r.text : r.text + '\n');
147
+ return r.code;
148
+ };
149
+ // 시작도 실패. --quiet 여도 이유는 반드시 적는다 스크립트를 고칠 사람이 볼 유일한 글이다.
150
+ const 못함 = (reason, message) => {
151
+ 삐끗(` ${c.red('✗')} ${message}`);
152
+ return 내놓기({
153
+ ok: false, reason, code: EXIT.error, text: '', why: message,
154
+ tools: 0, steps: 0, usage: { in: 0, out: 0, calls: 0, ms: 0, retries: 0 }, ms: 0,
155
+ });
156
+ };
157
+
158
+ // ── 시킬 말 ───────────────────────────────────────────────────────────
159
+ // 인자로 것이 먼저다. 없을 때만 표준입력을 읽는다 —
160
+ // 둘 다 있을 때 무엇이 이기는지가 헷갈리면 스크립트가 조용히 엉뚱한 일을 한다.
161
+ let 시킬말 = String(opts.prompt ?? '').trim();
162
+ if (!시킬말) 시킬말 = (await 표준입력읽기()).trim();
163
+ if (!시킬말) {
164
+ return 못함('no-prompt', '무엇을 시킬지 적어 주세요 — deel run "..." 또는 echo "..." | deel run');
165
+ }
166
+
167
+ const cfg = load();
168
+ const prof = activeProfile(cfg);
169
+ if (!prof) return 못함('no-config', '저장된 연결이 없습니다. deel setup 을 먼저 실행하세요.');
170
+
171
+ // 열쇠를 방금 잠갔으면 알린다. 여기서는 stdout 을 안 쓴다 —
172
+ // 배치로 부르는 쪽이 stdout 을 JSON 으로 읽고 있을 수 있다.
173
+ const 잠금 = 잠금소식();
174
+ if (잠금) { try { process.stderr.write(` ${잠금}\n`); } catch { /* 못 써도 그만 */ } }
175
+
176
+ const root = opts.root ? String(opts.root) : process.cwd();
177
+ const conn = {
178
+ kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
179
+ key: resolveKey(prof), model: prof.model,
180
+ /*
181
+ * 열쇠를 갖고 있는 대신 **받아 오는** 설정 (safety/authcmd.js).
182
+ *
183
+ * 여기서는 설정만 싣는다. 명령은 요청 직전에 부른다 — 켤 때 한 번
184
+ * 받아 두면 한 시간짜리 토큰이 세 시간짜리 대화 한복판에서 죽고,
185
+ * 401 화면에서 「열쇠가 틀렸다」 구별이 된다.
186
+ */
187
+ 열쇠받기: 받기설정(prof, { 정책값: 정책읽기().값 }),
188
+ ctx: opts.ctx ?? prof.ctx ?? CTX_DEFAULT,
189
+ // 답 길이 상한 — deel --max-tokens 32k 로 높일 수 있다(대화 화면의 /out 과 같은 값).
190
+ maxTokens: opts.maxTokens ?? prof.maxTokens ?? null,
191
+ streaming: prof.streaming ?? false,
192
+ tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
193
+ vision: prof.vision ?? false,
194
+ };
195
+
196
+ /*
197
+ * 잠근 열쇠를 못 풀었으면 그 까닭을 표준오류로 낸다.
198
+ *
199
+ * 배치로 부르는 쪽은 표준출력을 JSON 으로 읽으므로 거기에 섞으면 안 된다.
200
+ * 그렇다고 조용히 넘기면 여기가 더 나쁘다 — 아무도 안 보고 있는 자리라
201
+ * 401 하나 남기고 끝난다. 위 잠금소식 과 같은 자리, 같은 규칙이다.
202
+ */
203
+ const 열쇠탈 = 열쇠탈소식();
204
+ if (열쇠탈) { try { process.stderr.write(` ${열쇠탈}\n`); } catch { /* 못 써도 그만 */ } }
205
+
206
+ /*
207
+ * 자물쇠는 대화 화면과 똑같이 건다. 비대화라고 느슨해질 이유가 없다 —
208
+ * 오히려 배치는 아무도 보는 자리라 단단해야 한다.
209
+ *
210
+ * 다른 것은 딱 하나, **물을 수가 없다**는 것이다. 파이프 뒤에는 답할 사람이
211
+ * 없다. 그래서 여기서는 묻는 대신 멈추고, 무엇을 붙이면 되는지를 말한다.
212
+ * 조용히 나가 버리면 스크립트가 바깥으로 나가고 있는 줄 아무도 모른다.
213
+ */
214
+ const 바깥연결 = 바깥인가(conn.base);
215
+ const 실행모드 = 지금모드({
216
+ online: opts.online === true,
217
+ // 관리 정책이 offline 을 못박아 뒀으면 옵션과 상관없이 켠다 (safety/policy.js).
218
+ offline: !!(opts.offline ?? prof.offline ?? cfg?.offline),
219
+ });
220
+ const 나감 = 나갈수있나(실행모드, { 바깥: 바깥연결, 허가: prof.online === true });
221
+ if (나감.물어볼까) {
222
+ const 어디 = (() => { try { return new URL(conn.base).host; } catch { return conn.base; } })();
223
+ return 못함('needs-online',
224
+ `이 연결은 컴퓨터 밖으로 나갑니다 (${주소가리기(어디)}). 여기서는 물어볼 수 없습니다.\n`
225
+ + ' 나가도 된다면 --online 붙이거나, 대화 화면(deel)에서 한 번 허락해 두세요.\n'
226
+ + ' 이 컴퓨터 안의 모델을 쓰려면 deel scan --save 로 붙이세요.');
227
+ }
228
+ allowEndpoint(conn.base);
229
+ if (실행모드.허가무시) setOffline(true);
230
+
231
+ const session = new Session(conn, {
232
+ root,
233
+ mode: opts.mode ?? 'auto',
234
+ work: opts.work ?? null,
235
+ think: opts.think ?? 'medium',
236
+ effort: opts.effort ?? 'save',
237
+ maxSteps: opts.maxSteps ?? null, // null 이면 작업 모드가 정한다
238
+ });
239
+ // 지금 어느 실행 모드인가. 화면이 첫 줄에 이걸 그린다(ui/status.js).
240
+ // session 실어 두는 까닭은, 대화 도중 /model 옮겨도 같은 자리를 보게 하려는 것이다.
241
+ session.실행모드 = 실행모드;
242
+
243
+ /*
244
+ * 전선 카드와 실행의 이름 (backend/wire.js).
245
+ *
246
+ * 여기는 배운 것을 읽을 자리가 없다(배치는 대화 이력을 들고 돈다).
247
+ * 그래도 짐작만으로 충분하다 — 짐작이 틀리면 첫 400 에서 그 자리 안에
248
+ * 고쳐서 다시 부른다. 이름을 실어 보내는 것은 한 번의 실행이 게이트웨이
249
+ * 대시보드에서 줄로 묶이게 하려는 것이다.
250
+ */
251
+ 전선붙이기(conn);
252
+ session.세션이름 = 세션이름짓기(newId());
253
+ conn.세션이름 = session.세션이름;
254
+
255
+ const found = discover(root);
256
+ session.skills = found.skills;
257
+ session.commands = found.commands;
258
+ session.plugins = found.plugins;
259
+
260
+ // ── 물어보는 자리를 전부 막는다 ────────────────────────────────────────
261
+ //
262
+ // 여기가 파일의 핵심이다. 물어보는 함수 하나라도 기다리게 두면 그것 하나로
263
+ // 배치가 선다. 그래서 자리 모두 '기다리지 않고 바로 답을 내는' 함수로 채운다.
264
+ //
265
+ // 승인은 기본이 거부다. 반대로 하면 된다 아무도 보는 자리에서
266
+ // 되돌릴 없는 명령이 조용히 돌아가는 것이 프로그램이 제일 피하려는 일이다.
267
+ // 정말 맡기고 싶은 사람은 --yes 로 그 뜻을 명시한다.
268
+ const 승인필요 = session.mode !== 'auto';
269
+ const 자동승인 = opts.yes === true;
270
+ const ctx = {
271
+ scope: makeScope(root),
272
+ // 도구가 번에 돌려줄 양을 값에서 뽑는다 (agent/budget.js).
273
+ // /model 갈아타면 conn 통째로 바뀌므로 그때마다 다시 읽는다.
274
+ get 모델컨텍스트() { return conn.ctx ?? null; },
275
+ // 모델이 그림을 볼 수 있나 — Read 가 그림을 만났을 때 무슨 말을 할지가 여기서 갈린다.
276
+ get 눈있나() { return !!conn.vision; },
277
+ // 적어 허락·금지 규칙 (safety/policy.js). 승인 모드보다 먼저 본다.
278
+ 규칙들: 규칙모으기(cfg),
279
+ history: new History(root),
280
+ audit: new Audit(root, { 열쇠들: 열쇠묻기(conn) }),
281
+ seen: new Set(),
282
+ skills: found.skills,
283
+ loadedSkills: new Set(),
284
+ // 고친 뒤 진단을 볼지 (lsp/diag.js). 아래 내놓기() 에서 반드시 거둔다 —
285
+ // 거두면 배치가 끝나고도 언어 서버가 폴더를 물고 남는다.
286
+ lsp: { 켬: true },
287
+ // 되물을 사람이 없으니 기본값을 그대로 돌려준다.
288
+ ask: async (_label, o = {}) => o?.def ?? '',
289
+ // 엑셀 암호를 여기서 기다리면 그대로 선다. 없다고 바로 답한다 —
290
+ // 도구는 null 을 받으면 '암호가 걸려 못 읽었다' 고 정확히 말한다.
291
+ askPassword: async () => null,
292
+ confirm: async (name, args) => {
293
+ const 무엇 = 도구줄(name, args);
294
+ if (자동승인) {
295
+ 곁(` ${c.yellow('!')} ${c.gray(`--yes 라서 묻지 않고 실행합니다 ${무엇}`)}`);
296
+ return true;
297
+ }
298
+ 곁(` ${c.yellow('⊘')} ${c.gray(`승인이 필요해 거부했습니다 (물어볼 사람이 없습니다) — ${무엇}`)}`);
299
+ return false;
300
+ },
301
+ };
302
+
303
+ // 거부당할 것을 모델에게 미리 알려 준다.
304
+ //
305
+ // 거부만 하고 이유를 안 알리면 모델은 같은 호출을 몇 번이고 다시 한다.
306
+ // 사람이 '안 돼요' 라고 한 줄 알고, 다시 물어보면 이번엔 된다고 믿는다.
307
+ // 그러면 걸음 수만 다 쓰고 아무것도 못 한 채 끝난다.
308
+ const 보낼글 = (승인필요 && !자동승인)
309
+ ? `${시킬말}\n\n(비대화 모드다. 사람이 없어 승인을 물어볼 없고, 승인이 필요한 도구 호출은 자동으로 거부된다.`
310
+ + ' 승인 없이 되는 방법을 골라라. 그래도 안 되면 무엇이 막혔는지 말로 알려라.)'
311
+ : 시킬말;
312
+
313
+ // ── 컨텍스트 길이 ─────────────────────────────────────────────────────
314
+ //
315
+ // 대화 화면과 같은 이유로 서버에 물어본다. 저장된 값을 믿으면 조용히 작아진다.
316
+ // 다만 알아낸 값을 설정 파일에 도로 적지는 않는다 배치는 같은 명령을 여러 개
317
+ // 동시에 띄우는 자리라, 그때마다 config.json 을 덮어쓰면 서로 밟는다.
318
+ // 이번 한 번만 쓰고 버린다.
319
+ if (opts.ctx == null) {
320
+ try {
321
+ const r = await probeCtx(conn, { timeout: 6000 });
322
+ if (r?.value) {
323
+ const = conn.ctx;
324
+ conn.ctx = r.value;
325
+ if (전 !== r.value) {
326
+ 곁(` ${mark.ok} ${c.gray(`컨텍스트를 ${전.toLocaleString()} → ${r.value.toLocaleString()} 로 맞췄습니다 (${r.source ?? '서버'}에서 읽음)`)}`);
327
+ }
328
+ }
329
+ } catch { /* 못 물어보면 설정값 그대로 간다. 여기서 멈출 일은 아니다 */ }
330
+ }
331
+
332
+ // 종합 모드면 한마디를 보고 알맞은 작업 모드로 옮긴다. 대화 화면과 같다.
333
+ session.routed = null;
334
+ if (session.work === 'auto') {
335
+ const 골라진 = route(시킬말);
336
+ if (골라진.mode) {
337
+ session.routed = 골라진.mode;
338
+ const w = getWork(골라진.mode);
339
+ 곁(` ${c.hcyan(w.glyph)} ${c.gray(`${w.name} (${w.en}) — 말 속에 ${골라진.why} 가 있어서`)}`);
340
+ }
341
+ }
342
+
343
+ // ── ─────────────────────────────────────────────────────────────
344
+ const turn = new AbortController();
345
+ const 끊김 = () => { if (!turn.signal.aborted) turn.abort(); };
346
+ process.on('SIGINT', 끊김);
347
+
348
+ const t0 = Date.now();
349
+ let reason = 'done';
350
+ let why = '';
351
+ let tools = 0;
352
+ let steps = 0;
353
+ let 이번단계글 = ''; // 지금 단계에서 모델이 흘린 글. 단계가 바뀌면 비운다
354
+ let 답 = null;
355
+ // 서버가 끝났다는 말 없이 멈춘 적이 있나. 뒤에 오는 done 이 이걸 못 지운다.
356
+ let 말없이끊겼나 = false;
357
+
358
+ try {
359
+ // 하위 작업 안쪽에서 온 이벤트는 그 겹만큼 들여 쓴다.
360
+ const 안쪽 = (ev) => (ev.depth ? ' '.repeat(ev.depth) : '');
361
+
362
+ for await (const ev of run(session, ctx, 보낼글, { signal: turn.signal })) {
363
+ switch (ev.type) {
364
+ case 'stage':
365
+ steps = ev.step;
366
+ 이번단계글 = '';
367
+ break;
368
+
369
+ // 중간 단계의 말은 표준출력에 안 싣는다. "이제 파일을 읽어보겠습니다" 까지
370
+ // 딸려 나가면 파이프 뒤에서 답만 골라 쓸 수가 없다.
371
+ case 'content':
372
+ 이번단계글 += ev.text;
373
+ break;
374
+
375
+ case 'tool': {
376
+ tools++;
377
+ /*
378
+ * 실패는 실패로 보여야 한다.
379
+ *
380
+ * 전에는 `error` 만 빨갛게 칠했다. 그런데 탈이 났는데 error 는 없는
381
+ * 도구가 있다 — Bash 는 종료코드 3 을, Verify 는 `탈 2개` 를 요약에
382
+ * 담아 돌려준다. 그것들이 회색으로 찍히면, 나중에 이 기록을 읽는
383
+ * 사람 눈에는 성공과 구별되지 않는다. `deel run` 의 출력은 곧
384
+ * 근거로 쓰이는 물건이라 구별이 사라지면 된다.
385
+ */
386
+ const 결과 = ev.result?.error
387
+ ? c.red(clip(String(ev.result.error).split('\n')[0], 70))
388
+ : ev.result?.failed
389
+ ? c.yellow(clip(ev.result?.summary ?? '실패', 70))
390
+ : c.gray(clip(ev.result?.summary ?? '완료', 70));
391
+ // 하위 작업 안쪽이면 한 단 들여 그린다. 안 그러면 하위가 만진 파일이
392
+ // 부모가 만진 것과 똑같이 찍혀서, 기록으로 읽을 때 구분이 안 된다.
393
+ 곁(`${안쪽(ev)} ${c.cyan('')} ${c.bold(도구줄(ev.name, ev.args))} ${결과}`);
394
+ /*
395
+ * 비밀이 지나갔으면 여기서도 적는다.
396
+ *
397
+ * 배치는 아무도 안 보는 자리이고, 이 기록이 나중에 근거가 된다.
398
+ * "열쇠가 대화에 들어갔다" 기록에서 빼면 된다 나중에
399
+ * 사고가 났을 때 언제 새어 나갔는지 알 방법이 없어진다.
400
+ */
401
+ if (ev.비밀) {
402
+ 삐끗(`${안쪽(ev)} ${ev.비밀.가렸나 ? c.gray('⊘') : c.yellow('!')} `
403
+ + `${c.gray(`${ev.비밀.말}${ev.비밀.가렸나 ? ' — 모델에 가려서 보냈습니다' : ' — 파일 내용은 가리지 않습니다'}`)}`);
404
+ }
405
+ break;
406
+ }
407
+
408
+ /*
409
+ * 하위 작업의 여닫는 줄.
410
+ *
411
+ * `deel run` 잡·CI 에서 돌고 기록이 나중에 근거가 된다. 하위가
412
+ * 무엇을 맡았고 끝냈는지 안 남으면, 파일 넷이 어디서 나왔는지를
413
+ * 기록만 보고는 알 수 없다.
414
+ */
415
+ case 'task_start':
416
+ (`${안쪽(ev)} ${c.magenta('⌥')} ${c.bold('하위 작업')} ${clip(ev.목적, 60)}`
417
+ + ` ${c.gray(`따로 떨어진 대화 · 최대 ${ev.steps}걸음`)}`);
418
+ break;
419
+
420
+ case 'task_done': {
421
+ const = ev.끝 ?? {};
422
+ const 잘됨 = 끝.type === 'done';
423
+ const 셈 = (끝.files ?? []).filter((f) => !f.dir && !f.missing).length;
424
+ 곁(`${안쪽(ev)} ${잘됨 ? c.green('') : c.yellow('⚠')} ${c.gray('하위 작업')} ${clip(ev.목적, 50)}`
425
+ + ` ${c.gray(잘됨 ? `끝냄 · 파일 ${셈}개 · ${끝.steps ?? 0}걸음` : `다 못 했습니다 (${끝.type}) · ${끝.steps ?? 0}걸음`)}`);
426
+ break;
427
+ }
428
+
429
+ case 'retry':
430
+ 곁(` ${c.yellow('↻')} ${c.gray(`${ev.why} — 상한을 ${ev.from} → ${ev.to} 로 올려 다시 부릅니다`)}`);
431
+ break;
432
+
433
+ // 서버가 잠깐 막아서 기다렸다 다시 부른 자리 (backend/retry.js).
434
+ // 배치 기록에 남아야 "그날 밤 왜 12분이 걸렸나" 를 나중에 읽을 수 있다.
435
+ case 'backoff':
436
+ // 맞고 물러난 것과 맞기 전에 비킨 것은 다른 일이다. 같은 말로 적으면
437
+ // 있지도 않은 429 가 배치 기록에 남는다.
438
+ 곁(ev.미리
439
+ ? ` ${c.yellow('')} ${c.gray(옮긴말('loop.quotaAhead', { 초: 알림채움(ev).초 }))}`
440
+ : ` ${c.yellow('↻')} ${c.gray(옮긴말('loop.backoff', 알림채움(ev)))}`);
441
+ break;
442
+
443
+ // 서버가받는 칸이 있어 전선 카드를 고쳤다 (backend/wire.js).
444
+ case 'wire':
445
+ 곁(` ${c.cyan('⚙')} ${c.gray(옮긴말('loop.wire', { 무엇: ev.무엇 }))}`);
446
+ break;
447
+
448
+ case 'folded':
449
+ 곁(` ${c.cyan('◲')} ${c.gray(`오래된 도구 결과 ${ev.접은것}개를 접었습니다 (${ev.아낀토큰.toLocaleString()} 토큰을 비움)`)}`);
450
+ break;
451
+
452
+ case 'images_folded':
453
+ 곁(` ${c.cyan('◲')} ${c.gray(`오래된 그림 ${(ev.뺀것들 ?? []).reduce((a3, x) => a3 + x.장수, 0)}장을 뺐습니다`)}`);
454
+ break;
455
+
456
+ case 'compacted':
457
+ 곁(` ${c.cyan('◱')} ${c.gray(`대화 ${ev.folded}개를 요약으로 접었습니다 (${ev.before.toLocaleString()} → ${ev.after.toLocaleString()} 토큰)`)}`);
458
+ break;
459
+
460
+ case 'compact_failed':
461
+ 곁(` ${c.gray(`(접지 못했습니다: ${ev.why})`)}`);
462
+ break;
463
+
464
+ /*
465
+ * ── 여기 넷은 여태 아예 안 보고 있었다 ────────────────────────
466
+ *
467
+ * 대화창은 넷 다 화면에 적어 준다. 한 방 실행은 안 적었다. 그래서
468
+ * 잘린 답이 **온전한 답인 척** 파이프 뒤로 넘어갔다. `deel -p`
469
+ * 잡·CI 에서 돌고 기록이 나중에 근거가 되는 물건이라, 여기서
470
+ * 빠지면 왜 반쪽인지 되짚을 자리가 아무 데도 없다.
471
+ */
472
+ case 'capped':
473
+ 곁(` ${c.yellow('⚠')} ${c.gray(옮긴말('ev.capped', { 한계: ev.cap.toLocaleString() }))}`);
474
+ break;
475
+
476
+ /*
477
+ * 말없이 끊긴 것은 **종료코드까지** 바꾼다.
478
+ *
479
+ * 뒤에 done 이 따라오므로 그대로 두면 0 으로 끝난다. 잘렸다는 사실을
480
+ * 화면에만 적고 코드로는 성공이라고 하면, 스크립트는 화면을 안 읽으니
481
+ * 아무 일도 없던 것이 된다. 다만 여기서 곧바로 reason 을 바꾸지는
482
+ * 않는다 — 뒤에 오는 done 이 덮어쓰기 때문에 표시만 해 두고
483
+ * 루프가 끝난 뒤에 판정한다.
484
+ */
485
+ case 'cutoff':
486
+ 말없이끊겼나 = true;
487
+ 곁(` ${c.yellow('⚠')} ${c.gray(옮긴말('ev.cutoff'))}`);
488
+ break;
489
+
490
+ case 'nudge':
491
+ 곁(` ${c.gray(`↺ ${ev.why === '요청누락'
492
+ ? 옮긴말('ev.nudgeMissed', { n: ev.빠진?.length ?? 0 })
493
+ : 옮긴말('ev.nudgeRead')}`)}`);
494
+ break;
495
+
496
+ /*
497
+ * 모델이 안 하겠다고 했다.
498
+ *
499
+ * 화면에만 적고 종료코드는 0 으로 두면, 뒤에 붙은 스크립트가 일이
500
+ * 된 줄 알고 다음 단계로 넘어간다. 거절은 **아무 일도 안 한 것**이라
501
+ * 그렇게 넘어가면 안 된다. 여기서 곧바로 정한다 — 이 뒤에는 done 이
502
+ * 안 온다(루프가 그 자리에서 끝낸다).
503
+ */
504
+ case 'refusal':
505
+ reason = 'refusal';
506
+ why = 옮긴말('run.refusal');
507
+ /*
508
+ * 모델이 **뭐라고 하면서** 거절했는지도 내놓는다.
509
+ *
510
+ * 이 규격도 거절 글을 조각으로 보내기는 하는데, 그 조각을 답
511
+ * 자리로 흘려보내지 않고 맨 끝에 한 번에 모아 준다(backend/adapter.js
512
+ * 의 흡수). 그래서 여기서 안 담으면 화면에도 파이프 뒤에도 아무 글이
513
+ * 안 남고 「거절당했습니다」 한 줄만 보인다 — 무엇을 고쳐 다시
514
+ * 물어야 할지 알 길이 없다.
515
+ */
516
+ if (ev.text) 답 = ev.text;
517
+ 곁(` ${c.yellow('⚠')} ${c.gray(옮긴말('run.refusal'))}`);
518
+ if (ev.왜 && ev.왜 !== 'refusal') 곁(` ${c.gray(옮긴말('run.refusalWhy', { 왜: ev.왜 }))}`);
519
+ break;
520
+
521
+ case 'note':
522
+ 곁(` ${c.gray(`· ${ev.text}`)}`);
523
+ break;
524
+
525
+ // 자리가 다 차서 비웠다. 오류가 아니라 이어 가는 중이라, 종료코드는 안 건드린다.
526
+ case 'reset':
527
+ 곁(` ${c.cyan('◱')} ${c.gray((ev.kept?.할일 ?? 0)
528
+ ? 옮긴말('ev.reset', { 버린수: ev.dropped, 할일: ev.kept.할일 })
529
+ : 옮긴말('ev.resetBare', { 버린수: ev.dropped }))}`);
530
+ break;
531
+ case 'learned':
532
+ 곁(` ${c.cyan('◎')} ${c.gray(ev.what === 'ctx'
533
+ ? 옮긴말('ev.learnedCtx', { 한계: ev.limit.toLocaleString() })
534
+ : 옮긴말('ev.learnedOut', { 한계: ev.limit.toLocaleString() }))}`);
535
+ break;
536
+
537
+ case 'limit':
538
+ reason = 'limit';
539
+ why = `도구 호출 ${ev.steps}회에서 멈췄습니다. 한 번에 하기엔 큰 일입니다 — 나눠서 시키세요.`;
540
+ break;
541
+
542
+ case 'stuck':
543
+ reason = 'stuck';
544
+ why = String(ev.why ?? '같은 자리에서 헛돌고 있어 멈췄습니다');
545
+ break;
546
+
547
+ case 'aborted':
548
+ reason = 'aborted';
549
+ why = '중단했습니다';
550
+ break;
551
+
552
+ case 'error':
553
+ reason = 'error';
554
+ why = String(ev.text ?? '알 수 없는 오류');
555
+ break;
556
+
557
+ case 'done':
558
+ reason = 'done';
559
+ 답 = ev.text ?? 이번단계글;
560
+ break;
561
+ }
562
+ }
563
+ } catch (err) {
564
+ reason = 'error';
565
+ why = String(err?.message ?? err);
566
+ } finally {
567
+ process.removeListener('SIGINT', 끊김);
568
+ }
569
+
570
+ // 끝까지 못 갔어도 여기까지 나온 말은 내준다. 빈손으로 돌려보내면
571
+ // 왜 안 됐는지 짐작할 거리조차 없다.
572
+ if (답 == null) 답 = 이번단계글;
573
+
574
+ /*
575
+ * 말없이 끊긴 답은 '다 됐다' 로 안 넘긴다.
576
+ *
577
+ * done 이 뒤따라오므로 그냥 두면 reason 이 done 으로 덮인다. 그런데 그
578
+ * 답은 중간에서 끊긴 물건이다. 여기서 갈라 주지 않으면 파이프 뒤 스크립트가
579
+ * 반쪽을 온전한 것으로 받아 쓴다 — 그러고도 아무 표시가 안 남는다.
580
+ * 진짜 탈(error·limit·stuck)이 이미 잡혔으면 그쪽이 먼저다.
581
+ */
582
+ if (말없이끊겼나 && reason === 'done') {
583
+ reason = 'cutoff';
584
+ why = 옮긴말('ev.cutoff');
585
+ }
586
+
587
+ const code = EXIT[reason] ?? EXIT.error;
588
+ if (why) 삐끗(` ${reason === 'done' ? c.gray('·') : c.red('✗')} ${why}`);
589
+ if (!json && !quiet) {
590
+ const 조각 = [`${((Date.now() - t0) / 1000).toFixed(1)}초`];
591
+ if (tools) 조각.push(`도구 ${tools}회`);
592
+ 조각.push(`↑${(session.usage.prompt || session.usage.in).toLocaleString()} ↓${session.usage.out.toLocaleString()}`);
593
+ 곁(` ${c.gray('── ' + 조각.join(' · '))}`);
594
+ }
595
+
596
+ return 내놓기({
597
+ ok: reason === 'done',
598
+ reason,
599
+ code,
600
+ text: String(답 ?? ''),
601
+ tools,
602
+ steps,
603
+ usage: {
604
+ /*
605
+ * `in` 은 여태와 같은 뜻으로 둔다 — 서버가 새로 읽었다고 센 만큼이다.
606
+ * 파이프 뒤 스크립트가 이 이름을 이미 쓰고 있어서 뜻을 바꾸면 안 된다.
607
+ * 보낸 것 전체는 이름을 새로 붙여 **더한다** (backend/adapter.js 의 보낸토큰).
608
+ */
609
+ in: session.usage.in, out: session.usage.out,
610
+ prompt: session.usage.prompt || session.usage.in,
611
+ cacheRead: session.usage.cacheRead ?? 0,
612
+ cacheWrite: session.usage.cacheWrite ?? 0,
613
+ calls: session.usage.calls, ms: session.usage.ms,
614
+ retries: session.usage.retries ?? 0,
615
+ },
616
+ model: conn.model,
617
+ ms: Date.now() - t0,
618
+ ...(why ? { why } : {}),
619
+ });
620
+ }