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
package/src/oneshot.js CHANGED
@@ -1,327 +1,390 @@
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 { run } from './agent/loop.js';
15
- import { Session } from './agent/session.js';
16
- import { makeScope } from './safety/guard.js';
17
- import { History } from './safety/undo.js';
18
- import { Audit } from './safety/audit.js';
19
- import { activeProfile, load, resolveKey } from './config.js';
20
- import { discover } from './skills/discover.js';
21
- import { allowEndpoint, setOffline } from './safety/network.js';
22
- import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
23
- import { route } from './agent/route.js';
24
- import { get as getWork } from './agent/modes.js';
25
-
26
- /**
27
- * 종료코드.
28
- *
29
- * 스크립트는 화면 글이 아니라 이 숫자만 본다. 그래서 '끝났다' 와 '끝난 척했다'
30
- * 반드시 갈라 놔야 한다 걸음 상한에 걸려 멈춘 것을 0 으로 돌려주면
31
- * 야간 배치가 아무 일도 하고 초록불을 켠다. 그게 제일 나쁜 결말이다.
32
- */
33
- export const EXIT = {
34
- done: 0, // 끝까지 답했다
35
- error: 1, // 오류로 끝났다 (연결 없음 · 시킬 말 없음 · 모델 오류)
36
- limit: 2, // 도구 호출 걸음 상한에 닿았다
37
- stuck: 3, // 같은 자리에서 헛돌아 스스로 멈췄다
38
- aborted: 4, // 도중에 끊겼다 (Ctrl+C)
39
- };
40
-
41
- /**
42
- * 표준입력에 실려 온 말을 통째로 읽는다.
43
- *
44
- * 터미널이면 안 읽는다. 사람이 앉아 있는 자리에서 읽으려 들면 아무도 안 치는
45
- * 입력을 영영 기다린다 그게 바로 모드가 없애려는 상황이다.
46
- */
47
- async function 표준입력읽기() {
48
- if (process.stdin.isTTY) return '';
49
- const 조각 = [];
50
- try {
51
- for await (const b of process.stdin) 조각.push(b);
52
- } catch { /* 파이프가 먼저 닫히면 읽은 데까지만 쓴다 */ }
53
- return Buffer.concat(조각).toString('utf8');
54
- }
55
-
56
- // 도구 한 줄 요약. 화면 그림이 아니라 로그에 남을 글이라 색을 아낀다.
57
- function 도구줄(name, args) {
58
- const a = args ?? {};
59
- const = a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ??
60
- (a.command ? String(a.command).replace(/\s+/g, ' ') : null) ??
61
- (Array.isArray(a.todos) ? `${a.todos.length}건` : '');
62
- const = clip(String(첫 ?? ''), 48);
63
- return `${name}${안 ? `(${안})` : ''}`;
64
- }
65
-
66
- /**
67
- * 턴만 돌린다.
68
- *
69
- * @param {object} opts
70
- * prompt 시킬 말. 없으면 표준입력에서 읽는다
71
- * root/mode/work/think/effort/ctx/offline 대화 시작 옵션과 같은 뜻
72
- * yes 승인이 필요한 것도 그냥 실행 (기본은 거부)
73
- * json 결과를 JSON 한 덩이로
74
- * quiet 도구가 무엇을 했는지를 적는다 (오류는 그래도 적는다)
75
- * @returns {Promise<number>} 종료코드
76
- */
77
- export async function runOnce(opts = {}) {
78
- const json = opts.json === true;
79
- const quiet = opts.quiet === true;
80
-
81
- // 갈래로 나눠 쓴다.
82
- // 표준출력 — 모델의 마지막 답만. 그래야 > 파일 이나 | grep 이 그대로 먹는다.
83
- // 표준오류 도구가 무엇을 했는지. 사람이 볼 것이지 넘겨줄 것이 아니다.
84
- // 이걸 섞으면 파이프 뒤에 붙은 명령이 도구 기록까지 받아 먹는다.
85
- const 삐끗 = (s = '') => process.stderr.write(s + '\n');
86
- const = (s = '') => { if (!quiet) 삐끗(s); };
87
-
88
- const 내놓기 = (r) => {
89
- if (json) process.stdout.write(JSON.stringify(r) + '\n');
90
- else if (r.text) process.stdout.write(r.text.endsWith('\n') ? r.text : r.text + '\n');
91
- return r.code;
92
- };
93
- // 시작도 한 실패. --quiet 여도 이유는 반드시 적는다 — 스크립트를 고칠 사람이 볼 유일한 글이다.
94
- const 못함 = (reason, message) => {
95
- 삐끗(` ${c.red('✗')} ${message}`);
96
- return 내놓기({
97
- ok: false, reason, code: EXIT.error, text: '', why: message,
98
- tools: 0, steps: 0, usage: { in: 0, out: 0, calls: 0, ms: 0 }, ms: 0,
99
- });
100
- };
101
-
102
- // ── 시킬 ───────────────────────────────────────────────────────────
103
- // 인자로 준 것이 먼저다. 없을 때만 표준입력을 읽는다 —
104
- // 있을 무엇이 이기는지가 헷갈리면 스크립트가 조용히 엉뚱한 일을 한다.
105
- let 시킬말 = String(opts.prompt ?? '').trim();
106
- if (!시킬말) 시킬말 = (await 표준입력읽기()).trim();
107
- if (!시킬말) {
108
- return 못함('no-prompt', '무엇을 시킬지 적어 주세요 deel run "..." 또는 echo "..." | deel run');
109
- }
110
-
111
- const cfg = load();
112
- const prof = activeProfile(cfg);
113
- if (!prof) return 못함('no-config', '저장된 연결이 없습니다. deel setup 을 먼저 실행하세요.');
114
-
115
- const root = opts.root ? String(opts.root) : process.cwd();
116
- const conn = {
117
- kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
118
- key: resolveKey(prof), model: prof.model,
119
- ctx: opts.ctx ?? prof.ctx ?? CTX_DEFAULT,
120
- // 길이 상한 deel --max-tokens 32k 로 높일 수 있다(대화 화면의 /out 과 같은 값).
121
- maxTokens: opts.maxTokens ?? prof.maxTokens ?? null,
122
- streaming: prof.streaming ?? false,
123
- tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
124
- };
125
-
126
- // 자물쇠는 대화 화면과 똑같이 건다. 비대화라고 느슨해질 이유가 없다
127
- // 오히려 배치는 아무도 보는 자리라 단단해야 한다.
128
- allowEndpoint(conn.base);
129
- if (opts.offline ?? prof.offline) setOffline(true);
130
-
131
- const session = new Session(conn, {
132
- root,
133
- mode: opts.mode ?? 'auto',
134
- work: opts.work ?? null,
135
- think: opts.think ?? 'medium',
136
- effort: opts.effort ?? 'save',
137
- maxSteps: opts.maxSteps ?? 24,
138
- });
139
-
140
- const found = discover(root);
141
- session.skills = found.skills;
142
- session.commands = found.commands;
143
- session.plugins = found.plugins;
144
-
145
- // ── 물어보는 자리를 전부 막는다 ────────────────────────────────────────
146
- //
147
- // 여기가 이 파일의 핵심이다. 물어보는 함수 하나라도 기다리게 두면 그것 하나로
148
- // 배치가 선다. 그래서 세 자리 모두 '기다리지 않고 바로 답을 내는' 함수로 채운다.
149
- //
150
- // 승인은 기본이 거부다. 반대로 하면 된다 아무도 보는 자리에서
151
- // 되돌릴 수 없는 명령이 조용히 돌아가는 것이 이 프로그램이 제일 피하려는 일이다.
152
- // 정말 맡기고 싶은 사람은 --yes 로 그 뜻을 명시한다.
153
- const 승인필요 = session.mode !== 'auto';
154
- const 자동승인 = opts.yes === true;
155
- const ctx = {
156
- scope: makeScope(root),
157
- history: new History(root),
158
- audit: new Audit(root),
159
- seen: new Set(),
160
- skills: found.skills,
161
- loadedSkills: new Set(),
162
- // 되물을 사람이 없으니 기본값을 그대로 돌려준다.
163
- ask: async (_label, o = {}) => o?.def ?? '',
164
- // 엑셀 암호를 여기서 기다리면 그대로 선다. 없다고 바로 답한다 —
165
- // 도구는 null 을 받으면 '암호가 걸려 못 읽었다' 고 정확히 말한다.
166
- askPassword: async () => null,
167
- confirm: async (name, args) => {
168
- const 무엇 = 도구줄(name, args);
169
- if (자동승인) {
170
- 곁(` ${c.yellow('!')} ${c.gray(`--yes 라서 묻지 않고 실행합니다 ${무엇}`)}`);
171
- return true;
172
- }
173
- 곁(` ${c.yellow('⊘')} ${c.gray(`승인이 필요해 거부했습니다 (물어볼 사람이 없습니다)${무엇}`)}`);
174
- return false;
175
- },
176
- };
177
-
178
- // 거부당할 것을 모델에게 미리 알려 준다.
179
- //
180
- // 거부만 하고 이유를 알리면 모델은 같은 호출을 몇 번이고 다시 한다.
181
- // 사람이 '안 돼요' 라고 알고, 다시 물어보면 이번엔 된다고 믿는다.
182
- // 그러면 걸음 수만 쓰고 아무것도 못 한 채 끝난다.
183
- const 보낼글 = (승인필요 && !자동승인)
184
- ? `${시킬말}\n\n(비대화 모드다. 사람이 없어 승인을 물어볼 수 없고, 승인이 필요한 도구 호출은 자동으로 거부된다.`
185
- + ' 승인 없이 되는 방법을 골라라. 그래도 안 되면 무엇이 막혔는지 말로 알려라.)'
186
- : 시킬말;
187
-
188
- // ── 컨텍스트 길이 ─────────────────────────────────────────────────────
189
- //
190
- // 대화 화면과 같은 이유로 서버에 물어본다. 저장된 값을 믿으면 조용히 작아진다.
191
- // 다만 알아낸 값을 설정 파일에 도로 적지는 않는다 배치는 같은 명령을 여러 개
192
- // 동시에 띄우는 자리라, 그때마다 config.json 을 덮어쓰면 서로 밟는다.
193
- // 이번 번만 쓰고 버린다.
194
- if (opts.ctx == null) {
195
- try {
196
- const r = await probeCtx(conn, { timeout: 6000 });
197
- if (r?.value) {
198
- const 전 = conn.ctx;
199
- conn.ctx = r.value;
200
- if (전 !== r.value) {
201
- 곁(` ${mark.ok} ${c.gray(`컨텍스트를 ${전.toLocaleString()} → ${r.value.toLocaleString()} 로 맞췄습니다 (${r.source ?? '서버'}에서 읽음)`)}`);
202
- }
203
- }
204
- } catch { /* 물어보면 설정값 그대로 간다. 여기서 멈출 일은 아니다 */ }
205
- }
206
-
207
- // 종합 모드면 한마디를 보고 알맞은 작업 모드로 옮긴다. 대화 화면과 같다.
208
- session.routed = null;
209
- if (session.work === 'auto') {
210
- const 골라진 = route(시킬말);
211
- if (골라진.mode) {
212
- session.routed = 골라진.mode;
213
- const w = getWork(골라진.mode);
214
- 곁(` ${c.hcyan(w.glyph)} ${c.gray(`${w.name} (${w.en}) 말 속에 ${골라진.why} 가 있어서`)}`);
215
- }
216
- }
217
-
218
- // ── ─────────────────────────────────────────────────────────────
219
- const turn = new AbortController();
220
- const 끊김 = () => { if (!turn.signal.aborted) turn.abort(); };
221
- process.on('SIGINT', 끊김);
222
-
223
- const t0 = Date.now();
224
- let reason = 'done';
225
- let why = '';
226
- let tools = 0;
227
- let steps = 0;
228
- let 이번단계글 = ''; // 지금 단계에서 모델이 흘린 글. 단계가 바뀌면 비운다
229
- let 답 = null;
230
-
231
- try {
232
- for await (const ev of run(session, ctx, 보낼글, { signal: turn.signal })) {
233
- switch (ev.type) {
234
- case 'stage':
235
- steps = ev.step;
236
- 이번단계글 = '';
237
- break;
238
-
239
- // 중간 단계의 말은 표준출력에 안 싣는다. "이제 파일을 읽어보겠습니다" 까지
240
- // 딸려 나가면 파이프 뒤에서 답만 골라 수가 없다.
241
- case 'content':
242
- 이번단계글 += ev.text;
243
- break;
244
-
245
- case 'tool': {
246
- tools++;
247
- const 결과 = ev.result?.error
248
- ? c.red(clip(String(ev.result.error).split('\n')[0], 70))
249
- : c.gray(clip(ev.result?.summary ?? '완료', 70));
250
- 곁(` ${c.cyan('')} ${c.bold(도구줄(ev.name, ev.args))} ${결과}`);
251
- break;
252
- }
253
-
254
- case 'retry':
255
- 곁(` ${c.yellow('↻')} ${c.gray(`${ev.why} 상한을 ${ev.from} → ${ev.to} 로 올려 다시 부릅니다`)}`);
256
- break;
257
-
258
- case 'compacted':
259
- (` ${c.cyan('◱')} ${c.gray(`대화 ${ev.folded}개를 요약으로 접었습니다 (${ev.before.toLocaleString()} ${ev.after.toLocaleString()} 토큰)`)}`);
260
- break;
261
-
262
- case 'compact_failed':
263
- 곁(` ${c.gray(`(접지 못했습니다: ${ev.why})`)}`);
264
- break;
265
-
266
- case 'limit':
267
- reason = 'limit';
268
- why = `도구 호출 ${ev.steps}회에서 멈췄습니다. 번에 하기엔 일입니다 — 나눠서 시키세요.`;
269
- break;
270
-
271
- case 'stuck':
272
- reason = 'stuck';
273
- why = String(ev.why ?? '같은 자리에서 헛돌고 있어 멈췄습니다');
274
- break;
275
-
276
- case 'aborted':
277
- reason = 'aborted';
278
- why = '중단했습니다';
279
- break;
280
-
281
- case 'error':
282
- reason = 'error';
283
- why = String(ev.text ?? '알 없는 오류');
284
- break;
285
-
286
- case 'done':
287
- reason = 'done';
288
- = ev.text ?? 이번단계글;
289
- break;
290
- }
291
- }
292
- } catch (err) {
293
- reason = 'error';
294
- why = String(err?.message ?? err);
295
- } finally {
296
- process.removeListener('SIGINT', 끊김);
297
- }
298
-
299
- // 끝까지 갔어도 여기까지 나온 말은 내준다. 빈손으로 돌려보내면
300
- // 됐는지 짐작할 거리조차 없다.
301
- if (답 == null) = 이번단계글;
302
-
303
- const code = EXIT[reason] ?? EXIT.error;
304
- if (why) 삐끗(` ${reason === 'done' ? c.gray('·') : c.red('')} ${why}`);
305
- if (!json && !quiet) {
306
- const 조각 = [`${((Date.now() - t0) / 1000).toFixed(1)}초`];
307
- if (tools) 조각.push(`도구 ${tools}회`);
308
- 조각.push(`↑${session.usage.in.toLocaleString()} ↓${session.usage.out.toLocaleString()}`);
309
- 곁(` ${c.gray('── ' + 조각.join(' · '))}`);
310
- }
311
-
312
- return 내놓기({
313
- ok: reason === 'done',
314
- reason,
315
- code,
316
- text: String(답 ?? ''),
317
- tools,
318
- steps,
319
- usage: {
320
- in: session.usage.in, out: session.usage.out,
321
- calls: session.usage.calls, ms: session.usage.ms,
322
- },
323
- model: conn.model,
324
- ms: Date.now() - t0,
325
- ...(why ? { why } : {}),
326
- });
327
- }
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 { run } from './agent/loop.js';
15
+ import { Session } from './agent/session.js';
16
+ import { makeScope } from './safety/guard.js';
17
+ import { History } from './safety/undo.js';
18
+ import { Audit } from './safety/audit.js';
19
+ import { activeProfile, load, resolveKey } from './config.js';
20
+ import { discover } from './skills/discover.js';
21
+ import { allowEndpoint, setOffline } from './safety/network.js';
22
+ import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
23
+ import { route } from './agent/route.js';
24
+ import { get as getWork } from './agent/modes.js';
25
+ import { 모두끝내기 as 일감모두끝내기, 일감인자 } from './tools/jobs.js';
26
+
27
+ /**
28
+ * 종료코드.
29
+ *
30
+ * 스크립트는 화면 글이 아니라 숫자만 본다. 그래서 '끝났다' '끝난 척했다'
31
+ * 반드시 갈라 놔야 한다 걸음 상한에 걸려 멈춘 것을 0 으로 돌려주면
32
+ * 야간 배치가 아무 일도 안 하고 초록불을 켠다. 그게 제일 나쁜 결말이다.
33
+ */
34
+ export const EXIT = {
35
+ done: 0, // 끝까지 답했다
36
+ error: 1, // 오류로 끝났다 (연결 없음 · 시킬 말 없음 · 모델 오류)
37
+ limit: 2, // 도구 호출 걸음 상한에 닿았다
38
+ stuck: 3, // 같은 자리에서 헛돌아 스스로 멈췄다
39
+ aborted: 4, // 도중에 끊겼다 (Ctrl+C)
40
+ };
41
+
42
+ /**
43
+ * 표준입력에 실려 온 말을 통째로 읽는다.
44
+ *
45
+ * 터미널이면 읽는다. 사람이 앉아 있는 자리에서 읽으려 들면 아무도 안 치는
46
+ * 입력을 영영 기다린다 — 그게 바로 이 모드가 없애려는 상황이다.
47
+ */
48
+ async function 표준입력읽기() {
49
+ if (process.stdin.isTTY) return '';
50
+ const 조각 = [];
51
+ try {
52
+ for await (const b of process.stdin) 조각.push(b);
53
+ } catch { /* 파이프가 먼저 닫히면 읽은 데까지만 쓴다 */ }
54
+ return Buffer.concat(조각).toString('utf8');
55
+ }
56
+
57
+ // 도구 줄 요약. 화면 그림이 아니라 로그에 남을 글이라 색을 아낀다.
58
+ function 도구줄(name, args) {
59
+ const a = args ?? {};
60
+ const 첫 = a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ?? a.목적 ??
61
+ (a.command ? String(a.command).replace(/\s+/g, ' ') : null) ??
62
+ // 뒤에서 도는 명령. 번호가 그 일감의 이름이라, 이게 없으면 기록에
63
+ // `Jobs()` 여러 줄 남아 나중에 무엇을 본 것인지 알 수 없다.
64
+ // 이름 고르기는 jobs.js 한 군데에만 둔다 (영문 이름도 받는다).
65
+ (() => { const g = 일감인자(a); return g.번호 != null ? `${g.번호}번${g.끝내기 ? ' · 끝내기' : ''}` : null; })() ??
66
+ // 한 번에 여러 파일을 쓴 경우. 기록에 빈 괄호만 남으면 나중에 이 줄로는
67
+ // 무엇을 만들었는지 수 없다 — `deel run` 의 출력은 곧 근거로 쓰인다.
68
+ (Array.isArray(a.files) && a.files.length
69
+ ? `${a.files[0]?.file_path ?? '?'}${a.files.length > 1 ? ` 외 ${a.files.length - 1}개` : ''}`
70
+ : null) ??
71
+ (Array.isArray(a.edits) && a.edits.length
72
+ ? `${a.edits[0]?.file_path ?? '?'}${a.edits.length > 1 ? ` 외 ${a.edits.length - 1}군데` : ''}`
73
+ : null) ??
74
+ (Array.isArray(a.paths) && a.paths.length ? `${a.paths.length}개` : null) ??
75
+ (Array.isArray(a.todos) ? `${a.todos.length}건` : '');
76
+ const 안 = clip(String(첫 ?? ''), 48);
77
+ return `${name}${안 ? `(${})` : ''}`;
78
+ }
79
+
80
+ /**
81
+ * 턴만 돌린다.
82
+ *
83
+ * @param {object} opts
84
+ * prompt 시킬 말. 없으면 표준입력에서 읽는다
85
+ * root/mode/work/think/effort/ctx/offline 대화 시작 옵션과 같은
86
+ * yes 승인이 필요한 것도 그냥 실행 (기본은 거부)
87
+ * json 결과를 JSON 한 덩이로
88
+ * quiet 도구가 무엇을 했는지를 안 적는다 (오류는 그래도 적는다)
89
+ * @returns {Promise<number>} 종료코드
90
+ */
91
+ export async function runOnce(opts = {}) {
92
+ const json = opts.json === true;
93
+ const quiet = opts.quiet === true;
94
+
95
+ // 두 갈래로 나눠 쓴다.
96
+ // 표준출력 — 모델의 마지막 답만. 그래야 > 파일 이나 | grep 이 그대로 먹는다.
97
+ // 표준오류 도구가 무엇을 했는지. 사람이 것이지 넘겨줄 것이 아니다.
98
+ // 이걸 섞으면 파이프 뒤에 붙은 명령이 도구 기록까지 받아 먹는다.
99
+ const 삐끗 = (s = '') => process.stderr.write(s + '\n');
100
+ const 곁 = (s = '') => { if (!quiet) 삐끗(s); };
101
+
102
+ const 내놓기 = (r) => {
103
+ /*
104
+ * 뒤에서 돌던 명령을 반드시 거둔다.
105
+ *
106
+ * 여기가 모드의 모든 끝맺음이 지나는 자리다. 배치는 이걸 빠뜨리면
107
+ * 제일 크게 다친다 — 잡이 끝났다고 표시된 뒤에도 dev 서버가 계속 돌고,
108
+ * 다음 잡이 같은 포트를 잡으려다 실패한다. 원인은 로그 어디에도 없다.
109
+ */
110
+ const 껐다 = 일감모두끝내기();
111
+ if (껐다) 곁(` ${mark.ok} ${c.gray(`뒤에서 돌던 명령 ${껐다}개를 껐습니다.`)}`);
112
+ if (json) process.stdout.write(JSON.stringify(r) + '\n');
113
+ else if (r.text) process.stdout.write(r.text.endsWith('\n') ? r.text : r.text + '\n');
114
+ return r.code;
115
+ };
116
+ // 시작도 한 실패. --quiet 여도 이유는 반드시 적는다 — 스크립트를 고칠 사람이 볼 유일한 글이다.
117
+ const 못함 = (reason, message) => {
118
+ 삐끗(` ${c.red('✗')} ${message}`);
119
+ return 내놓기({
120
+ ok: false, reason, code: EXIT.error, text: '', why: message,
121
+ tools: 0, steps: 0, usage: { in: 0, out: 0, calls: 0, ms: 0 }, ms: 0,
122
+ });
123
+ };
124
+
125
+ // ── 시킬 말 ───────────────────────────────────────────────────────────
126
+ // 인자로 것이 먼저다. 없을 때만 표준입력을 읽는다
127
+ // 있을 무엇이 이기는지가 헷갈리면 스크립트가 조용히 엉뚱한 일을 한다.
128
+ let 시킬말 = String(opts.prompt ?? '').trim();
129
+ if (!시킬말) 시킬말 = (await 표준입력읽기()).trim();
130
+ if (!시킬말) {
131
+ return 못함('no-prompt', '무엇을 시킬지 적어 주세요 — deel run "..." 또는 echo "..." | deel run');
132
+ }
133
+
134
+ const cfg = load();
135
+ const prof = activeProfile(cfg);
136
+ if (!prof) return 못함('no-config', '저장된 연결이 없습니다. deel setup 을 먼저 실행하세요.');
137
+
138
+ const root = opts.root ? String(opts.root) : process.cwd();
139
+ const conn = {
140
+ kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
141
+ key: resolveKey(prof), model: prof.model,
142
+ ctx: opts.ctx ?? prof.ctx ?? CTX_DEFAULT,
143
+ // 길이 상한 — deel --max-tokens 32k 로 높일 수 있다(대화 화면의 /out 과 같은 값).
144
+ maxTokens: opts.maxTokens ?? prof.maxTokens ?? null,
145
+ streaming: prof.streaming ?? false,
146
+ tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
147
+ };
148
+
149
+ // 자물쇠는 대화 화면과 똑같이 건다. 비대화라고 느슨해질 이유가 없다 —
150
+ // 오히려 배치는 아무도보는 자리라 단단해야 한다.
151
+ allowEndpoint(conn.base);
152
+ if (opts.offline ?? prof.offline) setOffline(true);
153
+
154
+ const session = new Session(conn, {
155
+ root,
156
+ mode: opts.mode ?? 'auto',
157
+ work: opts.work ?? null,
158
+ think: opts.think ?? 'medium',
159
+ effort: opts.effort ?? 'save',
160
+ maxSteps: opts.maxSteps ?? null, // null 이면 작업 모드가 정한다
161
+ });
162
+
163
+ const found = discover(root);
164
+ session.skills = found.skills;
165
+ session.commands = found.commands;
166
+ session.plugins = found.plugins;
167
+
168
+ // ── 물어보는 자리를 전부 막는다 ────────────────────────────────────────
169
+ //
170
+ // 여기가 파일의 핵심이다. 물어보는 함수 하나라도 기다리게 두면 그것 하나로
171
+ // 배치가 선다. 그래서 세 자리 모두 '기다리지 않고 바로 답을 내는' 함수로 채운다.
172
+ //
173
+ // 승인은 기본이 거부다. 반대로 하면 된다 아무도 안 보는 자리에서
174
+ // 되돌릴 수 없는 명령이 조용히 돌아가는 것이 이 프로그램이 제일 피하려는 일이다.
175
+ // 정말 맡기고 싶은 사람은 --yes 로 그 뜻을 명시한다.
176
+ const 승인필요 = session.mode !== 'auto';
177
+ const 자동승인 = opts.yes === true;
178
+ const ctx = {
179
+ scope: makeScope(root),
180
+ // 도구가 번에 돌려줄 양을 값에서 뽑는다 (agent/budget.js).
181
+ // /model 갈아타면 conn 통째로 바뀌므로 그때마다 다시 읽는다.
182
+ get 모델컨텍스트() { return conn.ctx ?? null; },
183
+ history: new History(root),
184
+ audit: new Audit(root),
185
+ seen: new Set(),
186
+ skills: found.skills,
187
+ loadedSkills: new Set(),
188
+ // 되물을 사람이 없으니 기본값을 그대로 돌려준다.
189
+ ask: async (_label, o = {}) => o?.def ?? '',
190
+ // 엑셀 암호를 여기서 기다리면 그대로 선다. 없다고 바로 답한다
191
+ // 도구는 null 받으면 '암호가 걸려 읽었다' 정확히 말한다.
192
+ askPassword: async () => null,
193
+ confirm: async (name, args) => {
194
+ const 무엇 = 도구줄(name, args);
195
+ if (자동승인) {
196
+ 곁(` ${c.yellow('!')} ${c.gray(`--yes 라서 묻지 않고 실행합니다 ${무엇}`)}`);
197
+ return true;
198
+ }
199
+ 곁(` ${c.yellow('⊘')} ${c.gray(`승인이 필요해 거부했습니다 (물어볼 사람이 없습니다) — ${무엇}`)}`);
200
+ return false;
201
+ },
202
+ };
203
+
204
+ // 거부당할 것을 모델에게 미리 알려 준다.
205
+ //
206
+ // 거부만 하고 이유를 안 알리면 모델은 같은 호출을 몇 번이고 다시 한다.
207
+ // 사람이 '안 돼요' 라고 알고, 다시 물어보면 이번엔 된다고 믿는다.
208
+ // 그러면 걸음 수만 다 쓰고 아무것도 못 한 채 끝난다.
209
+ const 보낼글 = (승인필요 && !자동승인)
210
+ ? `${시킬말}\n\n(비대화 모드다. 사람이 없어 승인을 물어볼 수 없고, 승인이 필요한 도구 호출은 자동으로 거부된다.`
211
+ + ' 승인 없이 되는 방법을 골라라. 그래도 안 되면 무엇이 막혔는지 말로 알려라.)'
212
+ : 시킬말;
213
+
214
+ // ── 컨텍스트 길이 ─────────────────────────────────────────────────────
215
+ //
216
+ // 대화 화면과 같은 이유로 서버에 물어본다. 저장된 값을 믿으면 조용히 작아진다.
217
+ // 다만 알아낸 값을 설정 파일에 도로 적지는 않는다 — 배치는 같은 명령을 여러 개
218
+ // 동시에 띄우는 자리라, 그때마다 config.json 을 덮어쓰면 서로 밟는다.
219
+ // 이번 번만 쓰고 버린다.
220
+ if (opts.ctx == null) {
221
+ try {
222
+ const r = await probeCtx(conn, { timeout: 6000 });
223
+ if (r?.value) {
224
+ const = conn.ctx;
225
+ conn.ctx = r.value;
226
+ if (전 !== r.value) {
227
+ 곁(` ${mark.ok} ${c.gray(`컨텍스트를 ${전.toLocaleString()} → ${r.value.toLocaleString()} 로 맞췄습니다 (${r.source ?? '서버'}에서 읽음)`)}`);
228
+ }
229
+ }
230
+ } catch { /* 못 물어보면 설정값 그대로 간다. 여기서 멈출 일은 아니다 */ }
231
+ }
232
+
233
+ // 종합 모드면 이 한마디를 보고 알맞은 작업 모드로 옮긴다. 대화 화면과 같다.
234
+ session.routed = null;
235
+ if (session.work === 'auto') {
236
+ const 골라진 = route(시킬말);
237
+ if (골라진.mode) {
238
+ session.routed = 골라진.mode;
239
+ const w = getWork(골라진.mode);
240
+ 곁(` ${c.hcyan(w.glyph)} ${c.gray(`${w.name} (${w.en}) 속에 ${골라진.why} 있어서`)}`);
241
+ }
242
+ }
243
+
244
+ // ── 한 턴 ─────────────────────────────────────────────────────────────
245
+ const turn = new AbortController();
246
+ const 끊김 = () => { if (!turn.signal.aborted) turn.abort(); };
247
+ process.on('SIGINT', 끊김);
248
+
249
+ const t0 = Date.now();
250
+ let reason = 'done';
251
+ let why = '';
252
+ let tools = 0;
253
+ let steps = 0;
254
+ let 이번단계글 = ''; // 지금 단계에서 모델이 흘린 글. 단계가 바뀌면 비운다
255
+ let = null;
256
+
257
+ try {
258
+ // 하위 작업 안쪽에서 온 이벤트는 그 겹만큼 들여 쓴다.
259
+ const 안쪽 = (ev) => (ev.depth ? ' '.repeat(ev.depth) : '');
260
+
261
+ for await (const ev of run(session, ctx, 보낼글, { signal: turn.signal })) {
262
+ switch (ev.type) {
263
+ case 'stage':
264
+ steps = ev.step;
265
+ 이번단계글 = '';
266
+ break;
267
+
268
+ // 중간 단계의 말은 표준출력에 싣는다. "이제 파일을 읽어보겠습니다" 까지
269
+ // 딸려 나가면 파이프 뒤에서 답만 골라 쓸 수가 없다.
270
+ case 'content':
271
+ 이번단계글 += ev.text;
272
+ break;
273
+
274
+ case 'tool': {
275
+ tools++;
276
+ /*
277
+ * 실패는 실패로 보여야 한다.
278
+ *
279
+ * 전에는 `error` 만 빨갛게 칠했다. 그런데 탈이 났는데 error 는 없는
280
+ * 도구가 있다 — Bash 는 종료코드 3 을, Verify 는 `탈 2개` 를 요약에
281
+ * 담아 돌려준다. 그것들이 회색으로 찍히면, 나중에 이 기록을 읽는
282
+ * 사람 눈에는 성공과 구별되지 않는다. `deel run` 의 출력은 곧
283
+ * 근거로 쓰이는 물건이라 구별이 사라지면 안 된다.
284
+ */
285
+ const 결과 = ev.result?.error
286
+ ? c.red(clip(String(ev.result.error).split('\n')[0], 70))
287
+ : ev.result?.failed
288
+ ? c.yellow(clip(ev.result?.summary ?? '실패', 70))
289
+ : c.gray(clip(ev.result?.summary ?? '완료', 70));
290
+ // 하위 작업 안쪽이면 한 단 들여 그린다. 안 그러면 하위가 만진 파일이
291
+ // 부모가 만진 것과 똑같이 찍혀서, 기록으로 읽을 때 구분이 안 된다.
292
+ 곁(`${안쪽(ev)} ${c.cyan('⏺')} ${c.bold(도구줄(ev.name, ev.args))} ${결과}`);
293
+ break;
294
+ }
295
+
296
+ /*
297
+ * 하위 작업의 여닫는 줄.
298
+ *
299
+ * `deel run` 잡·CI 에서 돌고 기록이 나중에 근거가 된다. 하위가
300
+ * 무엇을 맡았고 끝냈는지 남으면, 파일 넷이 어디서 나왔는지를
301
+ * 기록만 보고는 없다.
302
+ */
303
+ case 'task_start':
304
+ (`${안쪽(ev)} ${c.magenta('')} ${c.bold('하위 작업')} ${clip(ev.목적, 60)}`
305
+ + ` ${c.gray(`따로 떨어진 대화 · 최대 ${ev.steps}걸음`)}`);
306
+ break;
307
+
308
+ case 'task_done': {
309
+ const = ev.끝 ?? {};
310
+ const 잘됨 = 끝.type === 'done';
311
+ const 셈 = (끝.files ?? []).filter((f) => !f.dir && !f.missing).length;
312
+ 곁(`${안쪽(ev)} ${잘됨 ? c.green('✓') : c.yellow('⚠')} ${c.gray('하위 작업')} ${clip(ev.목적, 50)}`
313
+ + ` ${c.gray(잘됨 ? `끝냄 · 파일 ${셈}개 · ${끝.steps ?? 0}걸음` : `다 했습니다 (${끝.type}) · ${끝.steps ?? 0}걸음`)}`);
314
+ break;
315
+ }
316
+
317
+ case 'retry':
318
+ 곁(` ${c.yellow('↻')} ${c.gray(`${ev.why} — 상한을 ${ev.from} → ${ev.to} 로 올려 다시 부릅니다`)}`);
319
+ break;
320
+
321
+ case 'compacted':
322
+ 곁(` ${c.cyan('◱')} ${c.gray(`대화 ${ev.folded}개를 요약으로 접었습니다 (${ev.before.toLocaleString()} → ${ev.after.toLocaleString()} 토큰)`)}`);
323
+ break;
324
+
325
+ case 'compact_failed':
326
+ 곁(` ${c.gray(`(접지 못했습니다: ${ev.why})`)}`);
327
+ break;
328
+
329
+ case 'limit':
330
+ reason = 'limit';
331
+ why = `도구 호출 ${ev.steps}회에서 멈췄습니다. 한 번에 하기엔 큰 일입니다 — 나눠서 시키세요.`;
332
+ break;
333
+
334
+ case 'stuck':
335
+ reason = 'stuck';
336
+ why = String(ev.why ?? '같은 자리에서 헛돌고 있어 멈췄습니다');
337
+ break;
338
+
339
+ case 'aborted':
340
+ reason = 'aborted';
341
+ why = '중단했습니다';
342
+ break;
343
+
344
+ case 'error':
345
+ reason = 'error';
346
+ why = String(ev.text ?? '알 수 없는 오류');
347
+ break;
348
+
349
+ case 'done':
350
+ reason = 'done';
351
+ 답 = ev.text ?? 이번단계글;
352
+ break;
353
+ }
354
+ }
355
+ } catch (err) {
356
+ reason = 'error';
357
+ why = String(err?.message ?? err);
358
+ } finally {
359
+ process.removeListener('SIGINT', 끊김);
360
+ }
361
+
362
+ // 끝까지 못 갔어도 여기까지 나온 말은 내준다. 빈손으로 돌려보내면
363
+ // 왜 안 됐는지 짐작할 거리조차 없다.
364
+ if (답 == null) 답 = 이번단계글;
365
+
366
+ const code = EXIT[reason] ?? EXIT.error;
367
+ if (why) 삐끗(` ${reason === 'done' ? c.gray('·') : c.red('✗')} ${why}`);
368
+ if (!json && !quiet) {
369
+ const 조각 = [`${((Date.now() - t0) / 1000).toFixed(1)}초`];
370
+ if (tools) 조각.push(`도구 ${tools}회`);
371
+ 조각.push(`↑${session.usage.in.toLocaleString()} ↓${session.usage.out.toLocaleString()}`);
372
+ 곁(` ${c.gray('── ' + 조각.join(' · '))}`);
373
+ }
374
+
375
+ return 내놓기({
376
+ ok: reason === 'done',
377
+ reason,
378
+ code,
379
+ text: String(답 ?? ''),
380
+ tools,
381
+ steps,
382
+ usage: {
383
+ in: session.usage.in, out: session.usage.out,
384
+ calls: session.usage.calls, ms: session.usage.ms,
385
+ },
386
+ model: conn.model,
387
+ ms: Date.now() - t0,
388
+ ...(why ? { why } : {}),
389
+ });
390
+ }