deel-local-cli 1.2.0 → 1.4.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.
@@ -0,0 +1,556 @@
1
+ // ACP 에이전트. 에디터 안에서 deel 을 쓰는 길이다.
2
+ //
3
+ // ── 무엇인가 ────────────────────────────────────────────────────────────
4
+ //
5
+ // ACP(Agent Client Protocol)는 에디터와 코딩 에이전트 사이의 말이다. 이걸
6
+ // 지키면 Zed·JetBrains·Neovim·Emacs 가 **자기 쪽을 안 고치고** deel 을 붙인다.
7
+ // 에디터가 deel 을 자식 프로세스로 띄우고, 표준입출력으로 JSON 줄을 주고받는다.
8
+ //
9
+ // ── 왜 이게 이 프로그램에 중요한가 ──────────────────────────────────────
10
+ //
11
+ // 사내에 반입한 도구는 "터미널을 하나 더 띄우세요" 를 못 넘는다. 개발자는
12
+ // 하루 종일 IDE 안에 있고, 창을 옮겨 다녀야 하는 도구는 두 주쯤 뒤에 안 쓴다.
13
+ // 반입 심사를 통과한 판이 아무도 안 쓰는 판이 되는 것이 제일 아까운 결말이다.
14
+ //
15
+ // 그리고 이 프로토콜은 deel 이 이미 가진 것과 잘 맞는다 — 로컬 모델을 쓰므로
16
+ // 코드가 밖으로 안 나가는데, 그 성질은 IDE 안에서 쓸 때 비로소 값이 붙는다.
17
+ //
18
+ // ── 딱 하나 지켜야 하는 것 ──────────────────────────────────────────────
19
+ //
20
+ // **표준출력에는 ACP 메시지 말고 아무것도 나가면 안 된다.**
21
+ //
22
+ // 규격이 그렇게 못 박아 두었고, 어기면 조용히 안 깨진다 — 에디터가 그 줄을
23
+ // 파싱하다 실패하고, 화면에는 "에이전트가 응답하지 않습니다" 만 뜬다. 원인이
24
+ // 어디에도 안 남는다. deel 안에는 say() 로 화면에 적는 자리가 수십 군데다.
25
+ // 그 중 하나라도 이 모드에서 불리면 관이 깨진다.
26
+ //
27
+ // 그래서 여기서 process.stdout.write 를 통째로 바꿔 끼운다. 부르는 자리를
28
+ // 하나하나 찾아 막는 방법도 있지만, 그건 앞으로 새로 쓰는 코드까지 계속
29
+ // 조심해야 한다는 뜻이다 — 언젠가 반드시 한 군데를 빠뜨린다.
30
+ import { VERSION } from '../version.js';
31
+ import { run } from '../agent/loop.js';
32
+ import { Session } from '../agent/session.js';
33
+ import { makeScope } from '../safety/guard.js';
34
+ import { History } from '../safety/undo.js';
35
+ import { Audit } from '../safety/audit.js';
36
+ import { activeProfile, load, resolveKey, homeDir } from '../config.js';
37
+ import { discover } from '../skills/discover.js';
38
+ import { allowEndpoint, setOffline } from '../safety/network.js';
39
+ import { probeCtx, 기본값 as CTX_DEFAULT } from '../backend/ctxsize.js';
40
+ import { 다붙이기 } from '../backend/mcp.js';
41
+ import { 배움 } from '../agent/evolve.js';
42
+ import { 카드 } from '../agent/card.js';
43
+ import { 못박기 } from '../agent/pins.js';
44
+ import { route } from '../agent/route.js';
45
+ import { ORDER as 모드순서, get as getWork, normalize as 모드정리 } from '../agent/modes.js';
46
+ import { 모두끝내기 as 일감모두끝내기 } from '../tools/jobs.js';
47
+ import { 연결, 줄나누기, 모르는방법오류, 잘못된인자오류 } from './jsonrpc.js';
48
+ import { 도구시작, 도구끝남, 도구이름표, 도구갈래, 도구자리, 멈춘까닭, 프롬프트글 } from './map.js';
49
+
50
+ /** 우리가 말하는 규격 판. 정수 하나이고, 깨지는 변경에서만 올라간다. */
51
+ export const 규격판 = 1;
52
+
53
+ /**
54
+ * 이 모드에서 표준출력을 잠근다.
55
+ *
56
+ * 잠그고 나면 say() 로 적힌 것은 전부 표준오류로 간다. 규격이 에이전트의
57
+ * 표준오류에는 무엇을 적어도 좋다고 허락하므로, 버리지 않고 그리로 돌린다 —
58
+ * 무언가 잘못됐을 때 그 글이 유일한 단서다.
59
+ *
60
+ * @returns {(줄: string) => void} 진짜 표준출력으로 쓰는 함수. ACP 만 이걸 쓴다
61
+ */
62
+ export function 표준출력잠그기() {
63
+ const 진짜 = process.stdout.write.bind(process.stdout);
64
+ process.stdout.write = function (덩이, enc, cb) {
65
+ return process.stderr.write(덩이, enc, cb);
66
+ };
67
+ return (줄) => 진짜(줄);
68
+ }
69
+
70
+ /**
71
+ * ACP 에이전트를 띄운다. 표준입력이 닫힐 때까지 산다.
72
+ *
73
+ * @param {object} opts root/mode/work/think/effort/ctx/offline — 대화 시작 옵션과 같은 뜻
74
+ * @returns {Promise<number>} 종료코드
75
+ */
76
+ export async function acp(opts = {}) {
77
+ const 내보내기 = 표준출력잠그기();
78
+ const 로그 = (s) => { try { process.stderr.write(`[acp] ${s}\n`); } catch { /* 여기서 또 터지면 할 게 없다 */ } };
79
+
80
+ /** 세션 하나. 에디터의 탭 하나에 해당한다. */
81
+ const 방들 = new Map();
82
+ let 다음방번호 = 1;
83
+ let 클라이언트 = null; // initialize 로 받은 저쪽 소개
84
+ let 시작했나 = false;
85
+
86
+ const 관 = new 연결({
87
+ 보내기: 내보내기,
88
+ 다루기: (방법, 인자) => 다루기(방법, 인자),
89
+ });
90
+
91
+ // ── 방 만들기 ─────────────────────────────────────────────────────────
92
+ async function 방만들기(요청) {
93
+ const cfg = load();
94
+ const prof = activeProfile(cfg);
95
+ if (!prof) {
96
+ throw 잘못된인자오류('저장된 연결이 없습니다. 터미널에서 `deel setup` 을 먼저 실행하세요.');
97
+ }
98
+
99
+ /*
100
+ * 작업 폴더는 에디터가 정한다.
101
+ *
102
+ * 규격은 cwd 를 절대 경로로 주라고 한다. 그 말을 믿고 그대로 쓴다 —
103
+ * 여기서 process.cwd() 로 대신하면 에디터가 연 프로젝트가 아니라 에디터를
104
+ * 띄운 자리를 기준으로 파일을 찾게 된다. 그러면 도구는 멀쩡히 도는데
105
+ * 엉뚱한 폴더를 고친다. 그게 제일 무서운 종류의 실수다.
106
+ */
107
+ const root = typeof 요청?.cwd === 'string' && 요청.cwd ? 요청.cwd : (opts.root ?? process.cwd());
108
+
109
+ const conn = {
110
+ kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
111
+ key: resolveKey(prof), model: prof.model,
112
+ ctx: opts.ctx ?? prof.ctx ?? CTX_DEFAULT,
113
+ maxTokens: opts.maxTokens ?? prof.maxTokens ?? null,
114
+ streaming: prof.streaming ?? false,
115
+ tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
116
+ };
117
+ allowEndpoint(conn.base);
118
+ if (opts.offline ?? prof.offline) setOffline(true);
119
+
120
+ const session = new Session(conn, {
121
+ root,
122
+ // 에디터에는 승인 창이 있다. 그러니 대화 화면과 같은 기본값을 쓴다 —
123
+ // 비대화 모드처럼 무조건 거부할 이유가 없다. 물어볼 데가 있기 때문이다.
124
+ mode: opts.mode ?? 'auto',
125
+ work: opts.work ?? null,
126
+ think: opts.think ?? 'medium',
127
+ effort: opts.effort ?? 'save',
128
+ });
129
+
130
+ const found = discover(root);
131
+ session.skills = found.skills;
132
+ session.commands = found.commands;
133
+ session.plugins = found.plugins;
134
+ session.못박은것 = new 못박기();
135
+
136
+ /*
137
+ * 밖에서 붙인 도구(MCP).
138
+ *
139
+ * 규격에는 클라이언트가 mcpServers 를 넘겨 주는 자리가 있다. 그런데 그걸
140
+ * 그대로 띄우면 **에디터 설정에 적힌 프로세스를 deel 이 대신 띄우는** 셈이
141
+ * 된다. 사내 반입 심사에서 "이 도구가 무엇을 띄우는가" 는 제일 먼저 묻는
142
+ * 것이라, 그 답이 '에디터가 시키는 대로' 가 되면 안 된다.
143
+ *
144
+ * 그래서 deel 은 늘 제 폴더의 .deel/mcp.json 만 본다. 사람이 직접 적은 것만
145
+ * 띄운다는 규칙이 대화 화면과 여기서 똑같이 유지된다.
146
+ */
147
+ const mcp붙임 = await 다붙이기(root, {
148
+ offline: !!(opts.offline ?? prof.offline),
149
+ audit: null,
150
+ });
151
+ session.mcp = mcp붙임.서버들;
152
+ if (Array.isArray(요청?.mcpServers) && 요청.mcpServers.length) {
153
+ 로그(`에디터가 MCP 서버 ${요청.mcpServers.length}대를 넘겼지만 띄우지 않습니다 — deel 은 .deel/mcp.json 에 적힌 것만 띄웁니다.`);
154
+ }
155
+
156
+ const 방 = {
157
+ id: `deel-${다음방번호++}`,
158
+ root, conn, session,
159
+ 턴: null,
160
+ 늘허락: new Set(), // 이 세션에서 "앞으로 묻지 않기" 를 고른 도구들
161
+ mcp: mcp붙임.서버들,
162
+ 도구번호: 0,
163
+ 메시지번호: 0,
164
+ 돌던도구: new Map(), // 도구 이름 → 아직 안 끝난 호출 아이디들
165
+ };
166
+
167
+ 방.ctx = {
168
+ scope: makeScope(root),
169
+ get 모델컨텍스트() { return conn.ctx ?? null; },
170
+ history: new History(root),
171
+ audit: new Audit(root),
172
+ seen: new Set(),
173
+ mcp: mcp붙임.서버들,
174
+ skills: found.skills,
175
+ loadedSkills: new Set(),
176
+ /*
177
+ * 되묻는 자리.
178
+ *
179
+ * ACP 1판에는 '아무거나 물어보기' 가 없다. 승인은 있어도 자유 질문은
180
+ * 없다. 그러니 기다리면 안 된다 — 기다리면 에디터는 아무 창도 안 띄우고
181
+ * deel 은 영영 서 있는다. 기본값을 바로 돌려준다.
182
+ */
183
+ ask: async (_라벨, o = {}) => o?.def ?? '',
184
+ askPassword: async () => null,
185
+ confirm: (이름, 인자) => 승인묻기(방, 이름, 인자),
186
+ };
187
+
188
+ 방.ctx.배움 = new 배움(root, homeDir());
189
+ session.배움요약 = 방.ctx.배움.요약(conn.model);
190
+ const 아는배수 = 방.ctx.배움.아는보정(conn.model);
191
+ if (아는배수) { session.보정 = 아는배수; session.보정잰것 = 1; }
192
+
193
+ 방.ctx.카드다시 = () => {
194
+ 방.ctx.카드 = 카드(conn.model, session.본것, 방.ctx.배움?.현황(conn.model)?.모델);
195
+ return 방.ctx.카드;
196
+ };
197
+ 방.ctx.카드다시();
198
+
199
+ // 컨텍스트 길이는 서버에 물어본다. 저장된 값을 믿으면 조용히 작아진다.
200
+ // 못 물어봐도 여기서 멈출 일은 아니다.
201
+ if (opts.ctx == null) {
202
+ try {
203
+ const r = await probeCtx(conn, { timeout: 6000 });
204
+ if (r?.value) conn.ctx = r.value;
205
+ } catch { /* 설정값 그대로 간다 */ }
206
+ }
207
+
208
+ 방들.set(방.id, 방);
209
+ return 방;
210
+ }
211
+
212
+ function 방찾기(id) {
213
+ const 방 = 방들.get(String(id ?? ''));
214
+ if (!방) throw 잘못된인자오류(`그런 세션이 없습니다: ${id}`);
215
+ return 방;
216
+ }
217
+
218
+ // ── 승인 ──────────────────────────────────────────────────────────────
219
+ //
220
+ // deel 의 안전장치를 에디터의 승인 창으로 그대로 내보낸다. 이게 붙는 것과
221
+ // 안 붙는 것의 차이가 크다 — 안 붙으면 위험한 명령을 물어볼 데가 없어서
222
+ // 무조건 거부하게 되고, 그러면 에디터 안에서는 아무 일도 못 하는 도구가 된다.
223
+ async function 승인묻기(방, 이름, 인자) {
224
+ if (방.늘허락.has(이름)) return true;
225
+
226
+ const 아이디 = `t${++방.도구번호}`;
227
+ try {
228
+ const 답 = await 관.요청('session/request_permission', {
229
+ sessionId: 방.id,
230
+ toolCall: {
231
+ toolCallId: 아이디,
232
+ title: 도구이름표(이름, 인자),
233
+ kind: 도구갈래(이름),
234
+ status: 'pending',
235
+ locations: 도구자리(이름, 인자, null),
236
+ rawInput: 인자 ?? {},
237
+ },
238
+ options: [
239
+ { optionId: 'allow_once', name: '이번만 실행', kind: 'allow_once' },
240
+ { optionId: 'allow_always', name: `${이름} 은 앞으로 묻지 않기`, kind: 'allow_always' },
241
+ { optionId: 'reject_once', name: '하지 않기', kind: 'reject_once' },
242
+ ],
243
+ });
244
+
245
+ const 결과 = 답?.outcome ?? {};
246
+ if (결과.outcome !== 'selected') return false; // cancelled 도 여기로 온다
247
+ if (결과.optionId === 'allow_always') { 방.늘허락.add(이름); return true; }
248
+ return 결과.optionId === 'allow_once';
249
+ } catch (err) {
250
+ /*
251
+ * 못 물어봤으면 안 한다.
252
+ *
253
+ * 여기서 true 를 돌려주고 싶은 유혹이 있다 — 안 그러면 승인 창을 아직
254
+ * 안 만든 클라이언트에서 아무것도 안 돌아가니까. 그런데 그건 "물어볼 수
255
+ * 없으면 마음대로 한다" 는 뜻이다. 사람이 안 보는 자리에서 되돌릴 수 없는
256
+ * 명령이 도는 것이 이 프로그램이 제일 피하려는 일이다.
257
+ */
258
+ 로그(`승인을 못 물어봐서 거부했습니다 (${이름}) — ${err?.message ?? err}`);
259
+ return false;
260
+ }
261
+ }
262
+
263
+ // ── 한 턴 ─────────────────────────────────────────────────────────────
264
+ async function 한턴(방, 덩이들) {
265
+ const 글 = 프롬프트글(덩이들);
266
+ if (!글) throw 잘못된인자오류('보낸 말이 비었습니다.');
267
+
268
+ // 앞 턴이 아직 돌고 있으면 끊고 시작한다. 규격은 턴을 겹쳐 보내지 말라고
269
+ // 하지만, 안 지키는 클라이언트가 있을 때 두 턴이 같은 세션을 같이 밟으면
270
+ // 오간 말이 뒤엉킨다. 그건 나중에 원인을 찾을 수 없는 종류의 고장이다.
271
+ if (방.턴 && !방.턴.signal.aborted) 방.턴.abort();
272
+
273
+ const 턴 = new AbortController();
274
+ 방.턴 = 턴;
275
+ 방.ctx.카드다시();
276
+
277
+ const 보내기 = (update) => 관.알림('session/update', { sessionId: 방.id, update });
278
+ const 말하기 = (글, 갈래 = 'agent_message_chunk') => 보내기({
279
+ sessionUpdate: 갈래,
280
+ content: { type: 'text', text: 글 },
281
+ messageId: `m${방.메시지번호}`,
282
+ });
283
+
284
+ // 종합 모드면 이 한마디를 보고 알맞은 작업 모드로 옮긴다. 대화 화면과 같다.
285
+ 방.session.routed = null;
286
+ if (방.session.work === 'auto') {
287
+ const 골라진 = route(글);
288
+ if (골라진.mode) 방.session.routed = 골라진.mode;
289
+ }
290
+
291
+ let 까닭 = 'done';
292
+ let 왜 = '';
293
+ 방.메시지번호++;
294
+
295
+ try {
296
+ for await (const ev of run(방.session, 방.ctx, 글, { signal: 턴.signal })) {
297
+ switch (ev.type) {
298
+ case 'stage':
299
+ 방.메시지번호++;
300
+ break;
301
+
302
+ case 'thinking':
303
+ if (ev.text) 말하기(ev.text, 'agent_thought_chunk');
304
+ break;
305
+
306
+ case 'content':
307
+ if (ev.text) 말하기(ev.text);
308
+ break;
309
+
310
+ /*
311
+ * 다시 부르는 자리.
312
+ *
313
+ * 답이 상한에서 잘리면 루프가 상한을 올려 처음부터 다시 부른다.
314
+ * 그러면 방금 흘려보낸 글이 통째로 다시 온다. 아무 말 없이 두 번
315
+ * 보내면 에디터에는 같은 답이 두 벌 붙어 보인다 — 모델이 헛소리를
316
+ * 하는 것처럼 보이지만 사실은 우리가 안 알려 준 탓이다.
317
+ */
318
+ case 'retry':
319
+ 방.메시지번호++;
320
+ 말하기(`\n\n_(${ev.why} — 다시 답을 받습니다)_\n\n`);
321
+ break;
322
+
323
+ case 'tool_start':
324
+ 보내기(도구시작(도구맡기기(방, ev.name), ev.name, ev.args));
325
+ break;
326
+
327
+ case 'tools_start':
328
+ for (const 이름 of ev.names ?? []) {
329
+ 보내기(도구시작(도구맡기기(방, 이름), 이름, null));
330
+ }
331
+ break;
332
+
333
+ case 'tool':
334
+ 보내기(도구끝남(도구찾기(방, ev.name), ev));
335
+ break;
336
+
337
+ /*
338
+ * 하위 작업.
339
+ *
340
+ * ACP 에는 '작업 안의 작업' 이 없다. 그래서 도구 호출 하나로 보이게
341
+ * 둔다 — 없는 척하면 하위가 만진 파일 넷이 어디서 나왔는지 화면만
342
+ * 보고는 알 수 없다.
343
+ */
344
+ case 'task_start':
345
+ 보내기({
346
+ sessionUpdate: 'tool_call',
347
+ toolCallId: 도구맡기기(방, 'Task'),
348
+ title: `하위 작업: ${ev.목적 ?? ''}`,
349
+ kind: 'think',
350
+ status: 'in_progress',
351
+ });
352
+ break;
353
+
354
+ case 'task_done':
355
+ 보내기({
356
+ sessionUpdate: 'tool_call_update',
357
+ toolCallId: 도구찾기(방, 'Task'),
358
+ status: ev.끝?.type === 'done' ? 'completed' : 'failed',
359
+ content: [{
360
+ type: 'content',
361
+ content: {
362
+ type: 'text',
363
+ text: ev.끝?.type === 'done'
364
+ ? `끝냄 · ${ev.끝?.steps ?? 0}걸음`
365
+ : `다 못 했습니다 (${ev.끝?.type}) · ${ev.끝?.steps ?? 0}걸음`,
366
+ },
367
+ }],
368
+ });
369
+ break;
370
+
371
+ case 'limit':
372
+ 까닭 = 'limit';
373
+ 왜 = `도구 호출 ${ev.steps}회에서 멈췄습니다. 한 번에 하기엔 큰 일입니다 — 나눠서 시키세요.`;
374
+ break;
375
+
376
+ case 'stuck':
377
+ 까닭 = 'stuck';
378
+ 왜 = String(ev.why ?? '같은 자리에서 헛돌고 있어 멈췄습니다.');
379
+ break;
380
+
381
+ case 'aborted':
382
+ 까닭 = 'aborted';
383
+ break;
384
+
385
+ case 'error':
386
+ 까닭 = 'error';
387
+ 왜 = String(ev.text ?? '알 수 없는 오류');
388
+ break;
389
+
390
+ case 'done':
391
+ 까닭 = 'done';
392
+ break;
393
+
394
+ default:
395
+ break;
396
+ }
397
+ }
398
+ } catch (err) {
399
+ 까닭 = 'error';
400
+ 왜 = String(err?.message ?? err);
401
+ } finally {
402
+ if (방.턴 === 턴) 방.턴 = null;
403
+ 방.돌던도구.clear();
404
+ }
405
+
406
+ /*
407
+ * 규격에 없는 까닭은 말로 준다.
408
+ *
409
+ * '헛돌아서 멈췄다' 는 stopReason 다섯 낱말 중 어디에도 없다. 억지로
410
+ * refusal 에 밀어 넣으면 에디터가 이 대화를 버려야 하는 것으로 읽는다.
411
+ * 그러니 낱말은 end_turn 으로 두고, 왜 멈췄는지는 사람이 읽게 적어 준다.
412
+ */
413
+ if (왜 && 까닭 !== 'aborted') {
414
+ 방.메시지번호++;
415
+ 말하기(`\n\n---\n**${까닭 === 'error' ? '오류' : '멈춤'}** — ${왜}\n`);
416
+ }
417
+
418
+ return { stopReason: 멈춘까닭(까닭) };
419
+ }
420
+
421
+ /*
422
+ * 도구 호출 하나에 번호를 매기고, 끝날 때 그 번호를 도로 찾는다.
423
+ *
424
+ * loop.js 는 시작 이벤트에 번호를 안 붙인다 — 화면에 그릴 때는 필요 없었다.
425
+ * ACP 는 시작과 끝을 같은 번호로 이어야 하나로 그린다. 그래서 이름별로
426
+ * 줄을 세워 두고 먼저 시작한 것부터 짝을 짓는다. 같은 이름을 나란히 여러 개
427
+ * 부르는 경우(도구를 한꺼번에 부를 때)에도 순서가 어긋나지 않는다.
428
+ */
429
+ function 도구맡기기(방, 이름) {
430
+ const 아이디 = `t${++방.도구번호}`;
431
+ const 줄 = 방.돌던도구.get(이름) ?? [];
432
+ 줄.push(아이디);
433
+ 방.돌던도구.set(이름, 줄);
434
+ return 아이디;
435
+ }
436
+
437
+ function 도구찾기(방, 이름) {
438
+ const 줄 = 방.돌던도구.get(이름);
439
+ if (줄?.length) return 줄.shift();
440
+ // 시작을 못 본 도구. 거부당한 호출처럼 시작 이벤트 없이 끝만 오는 자리가
441
+ // 있다. 새 번호를 준다 — 짝이 없다고 버리면 그 호출이 화면에서 사라진다.
442
+ return `t${++방.도구번호}`;
443
+ }
444
+
445
+ // ── 방법표 ────────────────────────────────────────────────────────────
446
+ async function 다루기(방법, 인자) {
447
+ switch (방법) {
448
+ case 'initialize': {
449
+ 시작했나 = true;
450
+ 클라이언트 = 인자?.clientInfo ?? null;
451
+ const 저쪽판 = Number(인자?.protocolVersion);
452
+ 로그(`붙었습니다 — ${클라이언트?.name ?? '이름 없는 클라이언트'} (규격 ${Number.isFinite(저쪽판) ? 저쪽판 : '?'}판)`);
453
+ return {
454
+ // 저쪽이 우리보다 새 판을 말하면 우리 판을 답한다. 규격이 그렇게 정했다.
455
+ protocolVersion: Number.isFinite(저쪽판) && 저쪽판 < 규격판 ? 저쪽판 : 규격판,
456
+ agentCapabilities: {
457
+ // session/load 는 아직 안 한다. 지난 대화를 되살리려면 오간 말을
458
+ // 전부 session/update 로 다시 흘려야 하는데, 그 자리를 반쯤 만들어
459
+ // 두면 에디터가 빈 대화를 열고 사용자는 기록이 날아간 줄 안다.
460
+ loadSession: false,
461
+ promptCapabilities: { image: false, audio: false, embeddedContext: true },
462
+ mcpCapabilities: { http: false, sse: false },
463
+ },
464
+ agentInfo: { name: 'deel', title: 'deel (로컬 모델 코딩 에이전트)', version: 판번호() },
465
+ authMethods: [], // 연결 설정은 `deel setup` 이 맡는다
466
+ };
467
+ }
468
+
469
+ case 'authenticate':
470
+ // 인증 방법을 하나도 안 걸었으니 여기 올 일이 없다. 와도 조용히 넘긴다.
471
+ return {};
472
+
473
+ case 'session/new': {
474
+ const 방 = await 방만들기(인자);
475
+ return {
476
+ sessionId: 방.id,
477
+ modes: 모드상태(방),
478
+ };
479
+ }
480
+
481
+ case 'session/prompt': {
482
+ const 방 = 방찾기(인자?.sessionId);
483
+ return await 한턴(방, 인자?.prompt);
484
+ }
485
+
486
+ case 'session/cancel': {
487
+ // 알림이다. 답하지 않는다 — 답하면 저쪽이 짝 없는 답을 받는다.
488
+ const 방 = 방들.get(String(인자?.sessionId ?? ''));
489
+ if (방?.턴 && !방.턴.signal.aborted) 방.턴.abort();
490
+ return undefined;
491
+ }
492
+
493
+ case 'session/set_mode': {
494
+ const 방 = 방찾기(인자?.sessionId);
495
+ const 고른것 = 모드정리(String(인자?.modeId ?? ''));
496
+ 방.session.work = 고른것;
497
+ 방.session.routed = null;
498
+ 로그(`작업 모드를 ${고른것} 로 바꿨습니다.`);
499
+ return {};
500
+ }
501
+
502
+ default:
503
+ throw 모르는방법오류(방법);
504
+ }
505
+ }
506
+
507
+ /*
508
+ * deel 의 작업 모드를 에디터의 모드 고르개로 내보낸다.
509
+ *
510
+ * 이게 붙으면 Zed 의 모드 단추가 deel 의 '계획 / 코드 / 설계' 를 그대로
511
+ * 고르게 된다. 프로토콜에 이미 있는 자리에 우리 것을 얹는 것이라 저쪽은
512
+ * 한 줄도 안 고쳐도 된다.
513
+ */
514
+ function 모드상태(방) {
515
+ return {
516
+ currentModeId: 방.session.work ?? 'auto',
517
+ availableModes: 모드순서.map((id) => {
518
+ const w = getWork(id);
519
+ return { id, name: `${w.glyph} ${w.name} (${w.en})`, description: w.hint ?? null };
520
+ }),
521
+ };
522
+ }
523
+
524
+ // ── 관 열기 ───────────────────────────────────────────────────────────
525
+ const 먹이기 = 줄나누기((줄) => 관.받았다(줄));
526
+ process.stdin.on('data', 먹이기);
527
+ process.stdin.resume();
528
+
529
+ await new Promise((끝) => {
530
+ const 마무리 = () => {
531
+ 관.닫기('에디터와의 관이 닫혔습니다');
532
+ 끝();
533
+ };
534
+ process.stdin.on('end', 마무리);
535
+ process.stdin.on('close', 마무리);
536
+ process.stdin.on('error', 마무리);
537
+ });
538
+
539
+ /*
540
+ * 뒤에서 돌던 명령을 반드시 거둔다.
541
+ *
542
+ * 에디터를 닫으면 우리 프로세스는 죽는데, 우리가 띄운 dev 서버는 안 죽는다.
543
+ * 다음에 열었을 때 포트가 잡혀 있고, 그 원인은 어디에도 안 남는다.
544
+ */
545
+ const 껐다 = 일감모두끝내기();
546
+ if (껐다) 로그(`뒤에서 돌던 명령 ${껐다}개를 껐습니다.`);
547
+ for (const 방 of 방들.values()) {
548
+ for (const s of 방.mcp ?? []) { try { s.닫기(); } catch { /* 이미 죽은 것 */ } }
549
+ }
550
+ if (!시작했나) 로그('initialize 를 못 받고 끝났습니다 — 이 명령은 에디터가 자식 프로세스로 띄우는 자리입니다.');
551
+ return 0;
552
+ }
553
+
554
+ // 판 번호는 version.js 한 곳에서만 읽는다. 여기서 또 읽으면 두 벌이 되고,
555
+ // 두 벌이 되면 언젠가 한쪽만 고쳐진다 — 그 파일이 존재하는 이유가 그것이다.
556
+ const 판번호 = () => VERSION;
@@ -0,0 +1,110 @@
1
+ // 모델 카드 — 겪어 본 버릇을 하네스 설정으로 바꾼다.
2
+ //
3
+ // ── 왜 만드나 ───────────────────────────────────────────────────────────
4
+ //
5
+ // 다른 도구들은 프론티어 모델을 전제한다. 여기 붙는 모델은 사정이 다르다.
6
+ // 2026년에 나온 재기들이 문서로 남긴 것만 봐도 이렇다.
7
+ //
8
+ // · 도구를 불러야 할 때와 아닐 때를 26.5~54% 헷갈린다 (3B~8B)
9
+ // · 7B 는 2~3걸음 뒤 일관성이 무너진다
10
+ // · 오류에서 회복할 때 같은 호출을 되풀이하거나 퇴행 루프에 빠진다
11
+ // · 파라미터 수보다 세대가 더 잘 맞힌다 (Qwen2.5 는 전부 실패, Qwen3 는 전부 통과)
12
+ //
13
+ // deel 은 이미 이걸 지켜보고 있었다(grade.js 의 지켜본것). 그런데 지켜보기만 하고
14
+ // **말로만** 넘겼다 — "인자를 자주 잘라 먹었으니 Append 를 써라" 하고 프롬프트에
15
+ // 적는 것이 전부였다. 작은 모델은 그 말을 잘 안 듣는다. 그게 작은 모델이다.
16
+ //
17
+ // ── 그래서 무엇이 다른가 ────────────────────────────────────────────────
18
+ //
19
+ // 카드는 모델에게 **부탁하는 대신 deel 이 제 행동을 바꾼다.**
20
+ //
21
+ // 인자가 자주 잘린다 → 출력 상한을 처음부터 넉넉히 준다 (잘린 뒤 다시 부르지 않게)
22
+ // 같은 것을 되풀이한다 → 되풀이 한계를 3 에서 2 로 좁힌다
23
+ // Edit 이 자주 빗나간다 → 빗나갔을 때 파일을 더 넓게 보여 준다
24
+ //
25
+ // 셋 다 모델의 협조가 필요 없다. 그게 요점이다.
26
+ //
27
+ // ── 안 하는 것 ──────────────────────────────────────────────────────────
28
+ //
29
+ // 조금 겪고 함부로 바꾸지 않는다. 걸음이 얼마 안 될 때 우연히 한 번 잘린 것으로
30
+ // 하네스를 조이면, 멀쩡한 모델을 붙들어 매는 셈이 된다 — 안 배우느니만 못하다.
31
+ // 그래서 걸음 상한과 비율 문턱을 둘 다 넘어야 움직인다.
32
+
33
+ /** 이 아래로는 판단하지 않는다. 걸음이 적으면 비율이 아무 뜻도 없다. */
34
+ export const 최소걸음 = 12;
35
+ /** 이 비율을 넘어야 '버릇' 으로 본다. */
36
+ export const 문턱 = 0.15;
37
+
38
+ /** 아무것도 안 겪었을 때의 값. 지금 하네스가 쓰는 그대로다. */
39
+ export function 기본조정() {
40
+ return {
41
+ // 잘린 뒤에 올리지 말고 처음부터 넉넉히 줄까 (loop.js 의 cap)
42
+ 상한먼저올리기: false,
43
+ // 같은 자리를 몇 번까지 봐 줄까 (loop.js 의 MAX_SAME)
44
+ 같은것한계: 3,
45
+ // Edit 이 빗나갔을 때 파일을 몇 줄이나 보여 줄까 (tools/index.js)
46
+ 빗나갔을때보일줄: 1,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * 카드 한 장.
52
+ *
53
+ * @param {string} 모델
54
+ * @param {object} 본것 이번 대화에서 본 것 (grade.js 의 지켜본것)
55
+ * @param {object} 겪은것 지난번까지 쌓인 것 (evolve.js 의 집.모델[모델])
56
+ */
57
+ export function 카드(모델, 본것 = null, 겪은것 = null) {
58
+ // 이번 것과 지난 것을 **합쳐서** 본다.
59
+ //
60
+ // 이번 대화에서 세 걸음 걸었는데 지난 대화에서 백 걸음을 걸었다면 그 백 걸음이
61
+ // 더 믿을 만하다. 켤 때마다 처음부터 다시 겪지 않는 것이 이 기능의 값이다.
62
+ const 셈 = (이름) => (Number(본것?.[이름]) || 0) + (Number(겪은것?.[이름]) || 0);
63
+ const 걸음 = 셈('걸음');
64
+
65
+ const 만들기 = (이름) => {
66
+ const n = 셈(이름);
67
+ return { n, 율: 걸음 ? n / 걸음 : 0 };
68
+ };
69
+ const 버릇 = {
70
+ 잘린인자: 만들기('잘린인자'),
71
+ 빈답: 만들기('빈답'),
72
+ 편집실패: 만들기('편집실패'),
73
+ 되풀이: 만들기('되풀이'),
74
+ };
75
+
76
+ const 조정 = 기본조정();
77
+ const 왜 = [];
78
+
79
+ // 걸음이 모자라면 아무것도 안 바꾼다. 여기서 일찍 나가는 것이 안전장치다.
80
+ if (걸음 >= 최소걸음) {
81
+ if (버릇.잘린인자.율 >= 문턱) {
82
+ 조정.상한먼저올리기 = true;
83
+ 왜.push(`인자가 ${백분율(버릇.잘린인자.율)} 잘렸습니다 — 잘린 뒤에 올리지 말고 처음부터 넉넉히 줍니다.`);
84
+ }
85
+ if (버릇.되풀이.율 >= 문턱) {
86
+ // 2 아래로는 안 내린다. 한 번에 끊으면 멀쩡한 재시도까지 막힌다 —
87
+ // 처음 실패하고 방법을 바꿔 다시 해 보는 것은 되풀이가 아니라 일하는 것이다.
88
+ 조정.같은것한계 = 2;
89
+ 왜.push(`같은 자리를 ${백분율(버릇.되풀이.율)} 되풀이했습니다 — 세 번까지 안 기다리고 두 번에서 끊습니다.`);
90
+ }
91
+ if (버릇.편집실패.율 >= 문턱) {
92
+ 조정.빗나갔을때보일줄 = 5;
93
+ 왜.push(`Edit 이 ${백분율(버릇.편집실패.율)} 빗나갔습니다 — 빗나갔을 때 파일을 더 넓게 보여 줍니다.`);
94
+ }
95
+ }
96
+
97
+ return {
98
+ 모델: String(모델 ?? ''),
99
+ 걸음,
100
+ 버릇,
101
+ 보정: Number(겪은것?.보정) || 1,
102
+ 조정,
103
+ 왜,
104
+ // 판단을 아직 못 하는 상태인지 화면이 알아야 한다 — '바꾼 것 없음' 과
105
+ // '아직 모름' 은 다른 말이다.
106
+ 아직모름: 걸음 < 최소걸음,
107
+ };
108
+ }
109
+
110
+ function 백분율(r) { return `${Math.round(r * 100)}%`; }