cdsa-harness 0.14.2 → 0.19.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.
package/src/cli.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  loadConfig,
18
18
  saveConfig,
19
19
  } from "./config.js";
20
- import { execFileSync } from "node:child_process";
20
+ import { execFileSync, execSync } from "node:child_process";
21
21
 
22
22
  import { AgentLoop, Step } from "./loop.js";
23
23
  import { LLMClient } from "./llm.js";
@@ -26,7 +26,8 @@ import { discoverNpmExtensions, loadPlugins } from "./plugins.js";
26
26
  import { SessionLog, sessionsDir } from "./session.js";
27
27
  import { loadSkills, renderSkill } from "./skills.js";
28
28
  import { Toolbox } from "./tools.js";
29
- import { c, panel, renderDiff, setColor } from "./ui.js";
29
+ import { c, panel, renderDiff, renderMarkdown, setColor } from "./ui.js";
30
+ import { makeCompleter } from "./completion.js";
30
31
 
31
32
  // VERSION 은 src/builtins.js(생성물)에서 가져온다 — npm/exe 양쪽에서 동일.
32
33
 
@@ -87,8 +88,8 @@ function printTeach(ev, stream) {
87
88
  lines.push(` ${roleColor(m.role.padEnd(9))} ${c.grey(`${m.chars}자${m.extra || ""}`)}`);
88
89
  }
89
90
  lines.push(c.grey(`제공 도구(${d.tools?.length || 0}): ${(d.tools || []).join(", ")}`));
90
- console.log(panel(lines, { title: `🧠 ② LLM 호출 — 반복 ${d.iteration}`, color: "magenta" }));
91
- if (d.systemPrompt) {
91
+ console.log(panel(lines, { title: `${d.sub ? "┆ " : ""}🧠 ② LLM 호출 — 반복 ${d.iteration}`, color: "magenta" }));
92
+ if (d.systemPrompt && !d.sub) {
92
93
  console.log(panel(clip(d.systemPrompt, 600).split("\n"), {
93
94
  title: "📜 시스템 프롬프트 (규칙+폴더가 여기 주입됨)",
94
95
  color: "grey",
@@ -112,14 +113,14 @@ function printTeach(ev, stream) {
112
113
  return;
113
114
  }
114
115
  const lines = [];
115
- if (ev.detail && ev.detail !== "(텍스트 없음)") lines.push(...clip(ev.detail, 1200).split("\n"));
116
+ if (ev.detail && ev.detail !== "(텍스트 없음)") lines.push(...renderMarkdown(clip(ev.detail, 1200)));
116
117
  for (const tc of d.toolCalls || []) {
117
118
  lines.push(c.yellow(`↳ 도구 호출 요청: ${c.bold(tc.name)}(${clip(JSON.stringify(tc.args), 200)})`));
118
119
  }
119
120
  const meta = replyMetaLine(d);
120
121
  if (meta) lines.push(c.grey("─ " + meta));
121
122
  else lines.push(c.dim("(mock: 토큰/지연 측정 없음)"));
122
- console.log(panel(lines.length ? lines : ["(빈 응답)"], { title: "🤖 ③ 모델 응답 (원본 판단)", color: "green" }));
123
+ console.log(panel(lines.length ? lines : ["(빈 응답)"], { title: `${d.sub ? "┆ " : ""}🤖 ③ 모델 응답 (원본 판단)`, color: "green" }));
123
124
  return;
124
125
  }
125
126
 
@@ -145,7 +146,8 @@ function printTeach(ev, stream) {
145
146
  return;
146
147
 
147
148
  case Step.DONE:
148
- console.log(panel((ev.detail || "완료").split("\n"), { title: "✅ 완료", color: "green" }));
149
+ if (d.sub) { console.log(c.grey("") + c.green("✅ 서브에이전트 완료")); return; }
150
+ console.log(panel(renderMarkdown(ev.detail || "완료"), { title: "✅ 완료", color: "green" }));
149
151
  return;
150
152
 
151
153
  case Step.ERROR:
@@ -166,10 +168,13 @@ function printCompact(ev, stream) {
166
168
  for (const tc of d.toolCalls || []) console.log(c.yellow(` ↳ ${tc.name}(${clip(JSON.stringify(tc.args), 120)})`));
167
169
  return;
168
170
  }
169
- if (ev.detail && ev.detail !== "(텍스트 없음)") console.log(panel(ev.detail.split("\n"), { title: "🤖 모델", color: "green" }));
171
+ if (ev.detail && ev.detail !== "(텍스트 없음)") console.log(panel(renderMarkdown(ev.detail), { title: `${d.sub ? " " : ""}🤖 모델`, color: "green" }));
170
172
  return;
171
173
  }
172
- if (ev.step === Step.DONE) return console.log(panel((ev.detail || "완료").split("\n"), { title: "✅ 완료", color: "green" }));
174
+ if (ev.step === Step.DONE) {
175
+ if (ev.data && ev.data.sub) return console.log(c.grey("┆ ") + c.green("✅ 서브에이전트 완료"));
176
+ return console.log(panel(renderMarkdown(ev.detail || "완료"), { title: "✅ 완료", color: "green" }));
177
+ }
173
178
  if (ev.step === Step.ERROR) return console.log(panel((ev.detail || "").split("\n"), { title: `❌ ${ev.title}`, color: "red" }));
174
179
  if (ev.step === Step.FEEDBACK) return;
175
180
  let detail = clip((ev.detail || "").trim().replace(/\s+/g, " "), 110);
@@ -178,11 +183,14 @@ function printCompact(ev, stream) {
178
183
  console.log(line);
179
184
  }
180
185
 
181
- function makeApproval(ask) {
186
+ function makeApproval(ask, cfg) {
187
+ let streak = 0; // 연속 승인 횟수 — 신뢰가 쌓이면 자동 수락을 제안한다
188
+ let hinted = false;
182
189
  return async (req) => {
183
- if (req.toolName === "write_file") {
190
+ if (req.toolName === "write_file" || req.toolName === "edit_file") {
191
+ const label = req.toolName === "edit_file" ? "부분 수정 제안" : "파일 쓰기 제안";
184
192
  console.log(panel(renderDiff(req.diff || "(변경 미리보기 없음)"), {
185
- title: `🔐 파일 수정 제안 — ${req.path}`,
193
+ title: `🔐 ${label} — ${req.path}`,
186
194
  color: "yellow",
187
195
  }));
188
196
  } else if (req.toolName === "run_shell") {
@@ -193,6 +201,11 @@ function makeApproval(ask) {
193
201
  const raw = await ask(c.yellow("이 작업을 승인하시겠습니까? [y/N] "));
194
202
  const ans = (raw || "").trim().toLowerCase();
195
203
  const approved = ans === "y" || ans === "yes";
204
+ streak = approved ? streak + 1 : 0;
205
+ if (approved && streak >= 3 && !hinted && cfg.approval_mode === "manual") {
206
+ hinted = true;
207
+ console.log(c.dim("💡 계속 승인 중이시네요 — 신뢰가 쌓였다면 ") + c.cyan("/auto") + c.dim(" 로 자동 수락을 켤 수 있어요."));
208
+ }
196
209
  return { approved, reason: approved ? "" : "사용자가 거부했습니다." };
197
210
  };
198
211
  }
@@ -256,6 +269,78 @@ async function buildExtensions(cfg, mcp) {
256
269
  return { toolbox, skills };
257
270
  }
258
271
 
272
+ async function fetchJson(url, headers = {}, timeoutMs = 4000) {
273
+ const ctrl = new AbortController();
274
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
275
+ try {
276
+ const res = await fetch(url, { headers, signal: ctrl.signal });
277
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
278
+ return await res.json();
279
+ } finally {
280
+ clearTimeout(t);
281
+ }
282
+ }
283
+
284
+ // 제공자별 '쓸 수 있는 모델' 목록. 가능하면 실시간(live), 실패 시 추천 목록으로 폴백.
285
+ async function fetchProviderModels(cfg, query = "") {
286
+ const q = query.trim().toLowerCase();
287
+ const key = cfg.resolvedKey();
288
+ try {
289
+ if (cfg.provider === "openrouter") {
290
+ const r = await fetchJson("https://openrouter.ai/api/v1/models");
291
+ let ids = (r.data || []).map((m) => m.id).sort();
292
+ if (q) ids = ids.filter((id) => id.toLowerCase().includes(q));
293
+ return { list: ids, live: true };
294
+ }
295
+ if (cfg.provider === "ollama") {
296
+ const host = (cfg.base_url || "http://localhost:11434/v1/chat/completions").replace(/\/v1\/.*$/, "");
297
+ const r = await fetchJson(`${host}/api/tags`, {}, 2500);
298
+ let ids = (r.models || []).map((m) => m.name);
299
+ if (q) ids = ids.filter((id) => id.toLowerCase().includes(q));
300
+ return { list: ids, live: true };
301
+ }
302
+ if (cfg.provider === "openai" && key) {
303
+ const r = await fetchJson("https://api.openai.com/v1/models", { Authorization: `Bearer ${key}` });
304
+ let ids = (r.data || []).map((m) => m.id).filter((id) => /^(gpt|o\d)/.test(id)).sort();
305
+ if (q) ids = ids.filter((id) => id.toLowerCase().includes(q));
306
+ return { list: ids, live: true };
307
+ }
308
+ if (cfg.provider === "anthropic" && key) {
309
+ const r = await fetchJson("https://api.anthropic.com/v1/models", { "x-api-key": key, "anthropic-version": "2023-06-01" });
310
+ let ids = (r.data || []).map((m) => m.id);
311
+ if (q) ids = ids.filter((id) => id.toLowerCase().includes(q));
312
+ return { list: ids, live: true };
313
+ }
314
+ } catch { /* 폴백으로 */ }
315
+ let list = SUGGESTED_MODELS[cfg.provider] || [];
316
+ if (q) list = list.filter((m) => m.toLowerCase().includes(q));
317
+ return { list, live: false };
318
+ }
319
+
320
+ // 번호로 고르는 모델 선택 UI. 선택하면 모델명, 취소하면 null 반환.
321
+ async function pickModel(ask, cfg, query = "") {
322
+ process.stdout.write(c.dim("모델 목록을 불러오는 중…\r"));
323
+ const { list, live } = await fetchProviderModels(cfg, query);
324
+ if (!list.length) {
325
+ console.log(c.yellow(`'${query}' 에 맞는 모델이 없습니다.`) + c.dim(" 예: /models claude · /models gpt"));
326
+ return null;
327
+ }
328
+ const shown = list.slice(0, 15);
329
+ const lines = shown.map((m, i) => {
330
+ const cur = m === cfg.model ? c.green(" ← 현재") : "";
331
+ return ` ${c.bold(String(i + 1).padStart(2))}) ${m}${cur}`;
332
+ });
333
+ if (list.length > shown.length) lines.push(c.dim(` … 외 ${list.length - shown.length}개 — '/models <검색어>' 로 좁히세요`));
334
+ lines.push(c.dim(live ? " (실시간 목록)" : " (추천 목록 — 키 연결 시 실시간 조회)"));
335
+ console.log(panel(lines, { title: `🤖 모델 선택 — ${cfg.provider}`, color: "cyan" }));
336
+ const a = await ask(c.cyan("번호 또는 모델명 입력 (엔터=취소): "));
337
+ if (a === null || !a.trim()) return null;
338
+ const s = a.trim();
339
+ const n = parseInt(s, 10);
340
+ if (Number.isInteger(n) && n >= 1 && n <= shown.length) return shown[n - 1];
341
+ return s; // 직접 입력한 이름 허용
342
+ }
343
+
259
344
  function makeClient(cfg) {
260
345
  return new LLMClient({
261
346
  provider: cfg.provider,
@@ -271,7 +356,11 @@ function printIntro(cfg) {
271
356
  console.log(renderBanner());
272
357
  console.log(c.dim("AI 에이전트의 내부 동작을 단계별로 드러내는 교육용 하네스") + " " + c.cyan("· made by CDSA"));
273
358
  console.log();
274
- const keySource = cfg.provider === "mock" ? "-" : cfg.api_key ? "config.json" : ENV_KEYS[cfg.provider] && process.env[ENV_KEYS[cfg.provider]] ? `환경변수 ${ENV_KEYS[cfg.provider]}` : c.red("없음");
359
+ const keySource =
360
+ cfg.provider === "mock" ? "-"
361
+ : cfg.provider === "ollama" ? "불필요(로컬)"
362
+ : cfg.api_key ? "config.json"
363
+ : ENV_KEYS[cfg.provider] && process.env[ENV_KEYS[cfg.provider]] ? `환경변수 ${ENV_KEYS[cfg.provider]}` : c.red("없음");
275
364
  const rows = [
276
365
  ["버전", `v${VERSION}`],
277
366
  ["provider", cfg.provider],
@@ -384,25 +473,26 @@ function printHelp() {
384
473
  [
385
474
  c.bold("사용법"),
386
475
  `시키고 싶은 일을 한국어로 입력하세요. 예) ${c.cyan("notes.txt 맨 아래에 할 일 3개 추가해줘")}`,
476
+ `${c.cyan("@파일명")} 을 쓰면 그 파일 내용이 자동 첨부됩니다. 예) ${c.cyan("@memo.txt 이거 요약해줘")}`,
477
+ "",
478
+ c.bold("대화"),
479
+ ` ${c.cyan("/new")}(=/clear) 새 대화 · ${c.cyan("/compact")} 대화를 모델이 요약해 압축 · ${c.cyan("/resume")} 지난 대화 이어가기`,
480
+ ` ${c.cyan("/undo")} 마지막 파일 변경 되돌리기 · ${c.cyan("/context")} 모델에 보내는 컨텍스트 보기`,
387
481
  "",
388
- c.bold("슬래시 명령"),
389
- ` ${c.cyan("/guide")} 처음 사용자용 빠른 시작 안내`,
390
- ` ${c.cyan("/tutorial")} 단계별 인터랙티브 튜토리얼`,
391
- ` ${c.cyan("/color")} 색상 켜기/끄기(흑백)`,
392
- ` ${c.cyan("/about")} 이 도구 정보(made by CDSA)`,
393
- ` ${c.cyan("/setup")} 제공자·API 키·모델 연결(대화형)`,
394
- ` ${c.cyan("/provider")} <openai|anthropic|openrouter|mock> 제공자 변경`,
395
- ` ${c.cyan("/model")} <이름> 모델 변경`,
396
- ` ${c.cyan("/teach")} 교육 모드 켜기/끄기(내부 과정 펼쳐보기)`,
397
- ` ${c.cyan("/stream")} 실시간 스트리밍 출력 켜기/끄기`,
398
- ` ${c.cyan("/context")} 지금 모델에 보내는 컨텍스트 들여다보기`,
482
+ c.bold("프로젝트"),
483
+ ` ${c.cyan("/init")} 모델이 폴더를 파악해 AGENT.md(프로젝트 규칙) 생성 · ${c.cyan("/memory")} 규칙 파일 보기`,
399
484
  ` ${c.cyan("/workspace")} <폴더> 작업 폴더 보기/변경 ('.' = 현재 폴더)`,
400
- ` ${c.cyan("/skills")} 스킬 목록(.cdsa/skills 의 /명령들)`,
401
- ` ${c.cyan("/plugins")} 플러그인 목록(파일·npm 추가 도구)`,
402
- ` ${c.cyan("/mcp")} 연결된 MCP 서버/도구(다른 에이전트와 공용)`,
403
- ` ${c.cyan("/reset")} 대화/컨텍스트 초기화`,
404
- ` ${c.cyan("/config")} 현재 설정값`,
405
- ` ${c.cyan("/quit")} 종료 (Ctrl+D)`,
485
+ "",
486
+ c.bold("모델·설정"),
487
+ ` ${c.cyan("/setup")} 연결 마법사 · ${c.cyan("/model")} <이름> 변경 · ${c.cyan("/models")} 목록(ollama 는 설치분) · ${c.cyan("/provider")} 전환`,
488
+ ` ${c.cyan("/auto")} 자동 수락 토글 · ${c.cyan("/status")}(=/cost) 상태·누적 토큰 · ${c.cyan("/update")} 자기 업데이트`,
489
+ ` ${c.cyan("/teach")} 내부과정 펼치기 · ${c.cyan("/stream")} 실시간 출력 · ${c.cyan("/color")} 색상`,
490
+ "",
491
+ c.bold("확장"),
492
+ ` ${c.cyan("/skills")} 스킬 목록 · ${c.cyan("/plugins")} 플러그인 · ${c.cyan("/mcp")} MCP 서버`,
493
+ "",
494
+ c.bold("기타"),
495
+ ` ${c.cyan("/guide")} 빠른 시작 · ${c.cyan("/tutorial")} 튜토리얼 · ${c.cyan("/about")} 정보 · ${c.cyan("/quit")} 종료(Ctrl+D)`,
406
496
  "",
407
497
  c.bold("교육 모드에서 보이는 단계"),
408
498
  " ① 입력 → ② LLM 호출(컨텍스트·도구) → ③ 모델 응답(토큰·지연) →",
@@ -427,13 +517,14 @@ async function runSetup(ask, cfg) {
427
517
  ` ${c.bold("1")}) openai (GPT, 키: ${ENV_KEYS.openai})`,
428
518
  ` ${c.bold("2")}) anthropic (Claude, 키: ${ENV_KEYS.anthropic})`,
429
519
  ` ${c.bold("3")}) openrouter (여러 모델 중계, 키: ${ENV_KEYS.openrouter})`,
430
- ` ${c.bold("4")}) mock (키 없이 연습)`,
520
+ ` ${c.bold("4")}) ollama (로컬·폐쇄망 LLM, 불필요) 🏢`,
521
+ ` ${c.bold("5")}) mock (키 없이 연습)`,
431
522
  ],
432
523
  { title: "🔌 연결 설정 (/setup)", color: "cyan" }
433
524
  ));
434
- const pickRaw = await ask(c.cyan("제공자 번호 [1-4] (취소: Enter): "));
525
+ const pickRaw = await ask(c.cyan("제공자 번호 [1-5] (취소: Enter): "));
435
526
  if (pickRaw === null) return false;
436
- const provider = { "1": "openai", "2": "anthropic", "3": "openrouter", "4": "mock" }[pickRaw.trim()];
527
+ const provider = { "1": "openai", "2": "anthropic", "3": "openrouter", "4": "ollama", "5": "mock" }[pickRaw.trim()];
437
528
  if (!provider) {
438
529
  console.log(c.yellow("취소했습니다."));
439
530
  return false;
@@ -442,6 +533,29 @@ async function runSetup(ask, cfg) {
442
533
 
443
534
  if (provider === "mock") {
444
535
  cfg.model = "mock-agent";
536
+ } else if (provider === "ollama") {
537
+ const hostAns = await ask(c.cyan("Ollama 주소 [http://localhost:11434]: "));
538
+ if (hostAns === null) return false;
539
+ const host = (hostAns.trim() || "http://localhost:11434").replace(/\/+$/, "");
540
+ cfg.base_url = `${host}/v1/chat/completions`;
541
+ cfg.api_key = "ollama"; // 로컬 LLM 은 키가 필요 없다(호환용 자리표시)
542
+ // 설치된 모델 자동 감지(/api/tags) — 실패해도 조용히 추천 목록 사용
543
+ let installed = [];
544
+ try {
545
+ const ctrl = new AbortController();
546
+ const t = setTimeout(() => ctrl.abort(), 2000);
547
+ const res = await fetch(`${host}/api/tags`, { signal: ctrl.signal });
548
+ clearTimeout(t);
549
+ if (res.ok) installed = ((await res.json()).models || []).map((m) => m.name);
550
+ } catch {
551
+ console.log(c.yellow(" (Ollama 에 연결하지 못했어요 — `ollama serve` 실행 여부를 확인하세요. 계속 진행합니다.)"));
552
+ }
553
+ if (installed.length) console.log(c.green(` 설치된 모델: ${installed.slice(0, 8).join(", ")}`));
554
+ const sugg = installed.length ? installed : SUGGESTED_MODELS.ollama;
555
+ const def = sugg[0];
556
+ const m = await ask(c.cyan(`모델 [${def}]: `));
557
+ if (m === null) return false;
558
+ cfg.model = m.trim() || def;
445
559
  } else {
446
560
  const envName = ENV_KEYS[provider];
447
561
  const envVal = (process.env[envName] || "").trim();
@@ -463,13 +577,9 @@ async function runSetup(ask, cfg) {
463
577
  }
464
578
  const sugg = SUGGESTED_MODELS[provider] || [];
465
579
  const def = sugg[0] || "";
466
- const note = provider === "openrouter" ? c.dim(" (OpenRouter 'provider/model' 형식)") : "";
467
- const m = await ask(c.cyan(`모델 [${def}]${note}\n 추천: ${sugg.join(", ")}\n > `));
468
- if (m === null) {
469
- console.log(c.yellow("취소했습니다."));
470
- return false;
471
- }
472
- cfg.model = m.trim() || def;
580
+ const picked = await pickModel(ask, cfg); // 실시간 목록에서 번호로 선택
581
+ cfg.model = picked || def;
582
+ if (!picked) console.log(c.dim(`기본 모델 사용: ${def}`));
473
583
  }
474
584
 
475
585
  const saved = saveConfig(cfg);
@@ -504,7 +614,7 @@ export async function main(argv = []) {
504
614
  "CDSA Harness — AI 에이전트 내부를 드러내는 교육용 하네스 (터미널)\n\n" +
505
615
  "사용법: cdsa-harness [옵션]\n" +
506
616
  " cdsa-harness add <npm-패키지> 플러그인 설치(이후 자동 로드)\n" +
507
- " --provider <openai|anthropic|openrouter|mock>\n" +
617
+ " --provider <openai|anthropic|openrouter|ollama|mock>\n" +
508
618
  " --model <모델명>\n" +
509
619
  " --workspace <폴더경로>\n" +
510
620
  " --setup 대화형 연결 설정 실행\n" +
@@ -546,12 +656,33 @@ export async function main(argv = []) {
546
656
  if (args.noColor) cfg.no_color = true;
547
657
  if (cfg.no_color) setColor(false); // 색상 끄기(흑백)
548
658
 
549
- const rl = readline.createInterface({ input: stdin, output: stdout });
659
+ // 명령 히스토리(↑↓)를 세션 간에도 기억한다.
660
+ const histPath = path.join(configDir(), "history");
661
+ let savedHistory = [];
662
+ try {
663
+ savedHistory = fs.readFileSync(histPath, "utf8").split("\n").filter(Boolean).slice(0, 200);
664
+ } catch { /* 첫 실행 */ }
665
+ // Tab 자동완성: 슬래시 명령(내장+스킬) · /provider 인자 · @파일 멘션
666
+ let toolbox = null;
667
+ let skills = {};
668
+ const completer = makeCompleter({
669
+ skills: () => skills,
670
+ workspace: () => (toolbox ? toolbox.workspace : null),
671
+ providers: PROVIDERS,
672
+ });
673
+ const rl = readline.createInterface({ input: stdin, output: stdout, history: savedHistory, historySize: 200, completer });
674
+ const saveHistory = () => {
675
+ try {
676
+ fs.mkdirSync(configDir(), { recursive: true });
677
+ fs.writeFileSync(histPath, (rl.history || []).slice(0, 200).join("\n"), "utf8");
678
+ } catch { /* ignore */ }
679
+ };
550
680
  let session = null;
551
681
  let mcp = { tools: [], servers: [], errors: [], closeAll: () => {} };
552
682
 
553
683
  // Ctrl+C → 깔끔하게 종료(스택트레이스 없이). 어디서 누르든 안전.
554
684
  const gracefulExit = () => {
685
+ saveHistory();
555
686
  try { rl.close(); } catch { /* */ }
556
687
  try { session && session.close(); } catch { /* */ }
557
688
  try { mcp && mcp.closeAll && mcp.closeAll(); } catch { /* */ }
@@ -559,14 +690,50 @@ export async function main(argv = []) {
559
690
  process.exit(0);
560
691
  };
561
692
  rl.on("SIGINT", gracefulExit);
562
- // 프롬프트 헬퍼: Ctrl+C(AbortError) 등은 null 돌려 호출부가 취소로 처리.
563
- const ask = async (q) => {
564
- try {
565
- return await rl.question(q);
566
- } catch {
567
- return null;
568
- }
569
- };
693
+ // 프롬프트 헬퍼. TTY rl.question 그대로(히스토리·편집 지원).
694
+ // 파이프/스크립트 입력(non-TTY) 줄을 큐로 받아 어떤 타이밍에 물어도 유실되지 않게 한다
695
+ // (모델 호출 중 도착한 승인 답변 줄이 사라지던 문제 해결 — CI/데모 자동화 가능).
696
+ let ask;
697
+ if (stdin.isTTY) {
698
+ ask = async (q) => {
699
+ try {
700
+ return await rl.question(q);
701
+ } catch {
702
+ return null;
703
+ }
704
+ };
705
+ } else {
706
+ const lineQueue = [];
707
+ let pendingResolve = null;
708
+ let closed = false;
709
+ rl.on("line", (l) => {
710
+ if (pendingResolve) {
711
+ const r = pendingResolve;
712
+ pendingResolve = null;
713
+ r(l);
714
+ } else lineQueue.push(l);
715
+ });
716
+ rl.on("close", () => {
717
+ closed = true;
718
+ if (pendingResolve) {
719
+ const r = pendingResolve;
720
+ pendingResolve = null;
721
+ r(null);
722
+ }
723
+ });
724
+ ask = (q) => {
725
+ process.stdout.write(q);
726
+ if (lineQueue.length) {
727
+ const l = lineQueue.shift();
728
+ process.stdout.write(l + "\n");
729
+ return Promise.resolve(l);
730
+ }
731
+ if (closed) return Promise.resolve(null);
732
+ return new Promise((res) => {
733
+ pendingResolve = res;
734
+ });
735
+ };
736
+ }
570
737
 
571
738
  if (args.setup) {
572
739
  await runSetup(ask, cfg);
@@ -582,7 +749,7 @@ export async function main(argv = []) {
582
749
  if (newer) {
583
750
  console.log(
584
751
  c.yellow(`⬆️ 새 버전 v${newer} 가 나왔어요!`) +
585
- c.dim(` 업데이트: npm i -g cdsa-harness@latest · exe 는 Releases 에서 새로 받기`) +
752
+ c.dim(` 지금 바로: `) + c.cyan("/update") + c.dim(` · exe 는 Releases 에서 새로 받기`) +
586
753
  "\n"
587
754
  );
588
755
  }
@@ -593,7 +760,7 @@ export async function main(argv = []) {
593
760
  mcp = await connectMcpServers(cfg.mcpServers);
594
761
  }
595
762
  // 플러그인·스킬·도구상자를 작업 폴더 기준으로 구성(작업 폴더 변경 시 재사용)
596
- let { toolbox, skills } = await buildExtensions(cfg, mcp);
763
+ ({ toolbox, skills } = await buildExtensions(cfg, mcp));
597
764
  if (toolbox.plugins.length || Object.keys(skills).length || toolbox.pluginErrors.length || mcp.servers.length) {
598
765
  const bits = [];
599
766
  if (toolbox.plugins.length) bits.push(c.green(`도구 +${toolbox.plugins.length}개`));
@@ -618,7 +785,7 @@ export async function main(argv = []) {
618
785
  client: makeClient(cfg),
619
786
  toolbox,
620
787
  onEvent: makePrinter(cfg, stream),
621
- approvalCallback: makeApproval(ask),
788
+ approvalCallback: makeApproval(ask, cfg),
622
789
  session,
623
790
  onToken,
624
791
  });
@@ -626,6 +793,37 @@ export async function main(argv = []) {
626
793
 
627
794
  const rule = () => console.log(c.grey("─".repeat(Math.min(80, stdout.columns || 80))));
628
795
 
796
+ // @파일 멘션: 입력 속 @경로 가 작업 폴더의 실제 파일이면 내용을 프롬프트에 첨부한다.
797
+ const expandMentions = (text) => {
798
+ const tokens = [...text.matchAll(/@([\w가-힣./\\-]+)/g)].map((m) => m[1]);
799
+ let out = text;
800
+ for (const t of [...new Set(tokens)]) {
801
+ try {
802
+ const r = toolbox.readFile(t);
803
+ const clipped = (r.output || "").slice(0, 4000);
804
+ out += `\n\n[첨부 @${t}]\n${clipped}`;
805
+ console.log(c.dim(`📎 @${t} 첨부됨 (${clipped.length}자)`));
806
+ } catch { /* 파일이 아니면 무시(이메일 등) */ }
807
+ }
808
+ return out;
809
+ };
810
+
811
+ // 대화 자동저장(/resume 용) + 멘션 확장을 겸하는 실행 헬퍼.
812
+ const convPath = path.join(configDir(), "last_session.json");
813
+ const runTurn = async (promptText) => {
814
+ rule();
815
+ try {
816
+ await loop.run(expandMentions(promptText));
817
+ } catch (e) {
818
+ console.log(c.red(`실행 오류: ${e?.message || e}`));
819
+ }
820
+ rule();
821
+ try {
822
+ fs.mkdirSync(configDir(), { recursive: true });
823
+ fs.writeFileSync(convPath, JSON.stringify({ time: Date.now(), provider: cfg.provider, model: cfg.model, messages: loop.messages }), "utf8");
824
+ } catch { /* 저장 실패 무시 */ }
825
+ };
826
+
629
827
  // 첫 실행 온보딩(한 번만 — ~/.cdsa_harness/.welcomed 표시): 작업 폴더 설정 + 튜토리얼
630
828
  const markerPath = path.join(configDir(), ".welcomed");
631
829
  if (stdin.isTTY && !fs.existsSync(markerPath)) {
@@ -695,7 +893,148 @@ export async function main(argv = []) {
695
893
  ], { title: "ℹ️ about", color: "cyan" }));
696
894
  continue;
697
895
  }
698
- if (low === "/reset") { loop.reset(); console.log(c.green("컨텍스트를 초기화했습니다.")); continue; }
896
+ // /clear·/new Claude Code·OpenCode 컨벤션(= 기존 /reset)
897
+ if (low === "/reset" || low === "/clear" || low === "/new") {
898
+ loop.reset();
899
+ console.log(c.green("새 대화를 시작합니다(컨텍스트 초기화)."));
900
+ continue;
901
+ }
902
+ // /compact — 대화를 '모델이 직접' 요약해 컨텍스트를 압축 (Claude Code 방식, 모델-퍼스트)
903
+ if (low === "/compact") {
904
+ if (loop.messages.length < 4) {
905
+ console.log(c.dim("아직 압축할 대화가 충분하지 않습니다."));
906
+ continue;
907
+ }
908
+ const before = loop.messages.length;
909
+ console.log(c.dim("모델에게 대화 요약을 맡겨 컨텍스트를 압축합니다…"));
910
+ try {
911
+ const req = [
912
+ ...loop.messages,
913
+ {
914
+ role: "user",
915
+ content:
916
+ "지금까지의 대화를 다음 작업에 꼭 필요한 것만 남긴 한국어 요약으로 만들어줘: 확인된 사실, 내린 결정, 파일 변경 내역, 미완료 작업. 요약문만 출력해.",
917
+ },
918
+ ];
919
+ const r = await loop.client.chat(req, []);
920
+ const summary = (r.content || "").trim();
921
+ if (!summary) throw new Error("빈 요약");
922
+ loop.reset();
923
+ loop.messages.push(
924
+ { role: "user", content: `[이전 대화 요약]\n${summary}` },
925
+ { role: "assistant", content: "요약을 확인했습니다. 이어서 진행하겠습니다." }
926
+ );
927
+ console.log(c.green(`압축 완료 — 메시지 ${before}개 → ${loop.messages.length}개`));
928
+ } catch (e) {
929
+ console.log(c.yellow(`압축 실패: ${e.message}`));
930
+ }
931
+ continue;
932
+ }
933
+ // /undo — 마지막 파일 변경 1건 되돌리기 (OpenCode 컨벤션)
934
+ if (low === "/undo") {
935
+ try {
936
+ console.log(c.green(toolbox.undoLast().output));
937
+ } catch (e) {
938
+ console.log(c.yellow(e.message));
939
+ }
940
+ continue;
941
+ }
942
+ // /update — 자기 자신을 최신 버전으로 업데이트(npm 설치본). exe 는 다운로드 안내.
943
+ if (low === "/update") {
944
+ let isSea = false;
945
+ try {
946
+ isSea = (await import("node:sea")).isSea();
947
+ } catch { /* 구버전 Node — SEA 아님 */ }
948
+ if (isSea) {
949
+ console.log(c.yellow("단일 실행파일(exe) 버전은 파일 교체 방식이라 자동 업데이트가 없어요."));
950
+ console.log(c.dim("최신 exe 다운로드: ") + c.cyan("https://github.com/cdsassj00/miniharness/releases/latest"));
951
+ continue;
952
+ }
953
+ console.log(c.cyan("최신 버전으로 업데이트합니다… (npm i -g cdsa-harness@latest)"));
954
+ try {
955
+ execSync("npm i -g cdsa-harness@latest", { stdio: "inherit" });
956
+ console.log(c.green("✅ 업데이트 완료! /quit 후 다시 실행하면 새 버전이 적용됩니다."));
957
+ } catch (e) {
958
+ console.log(c.red(`업데이트 실패: ${e.message}`));
959
+ console.log(c.dim("직접 실행: npm i -g cdsa-harness@latest"));
960
+ }
961
+ continue;
962
+ }
963
+ // /auto — 자동 수락 토글(신뢰 전환). Claude Code 의 auto-accept 에 해당.
964
+ if (low === "/auto") {
965
+ cfg.approval_mode = cfg.approval_mode === "auto" ? "manual" : "auto";
966
+ console.log(
967
+ cfg.approval_mode === "auto"
968
+ ? c.green("자동 수락 ON — 파일 수정·셸을 묻지 않고 실행합니다. (해제: /auto)")
969
+ : c.green("자동 수락 OFF — 변경 전 diff 를 보여주고 승인을 받습니다.")
970
+ );
971
+ continue;
972
+ }
973
+ // /init — '모델에게' 폴더를 파악시켜 AGENT.md 프로젝트 규칙을 만들게 한다 (Claude Code /init)
974
+ if (low === "/init") {
975
+ await runTurn(
976
+ "이 작업 폴더를 list_dir/read_file/search_files 로 파악한 뒤, 앞으로의 에이전트 작업 지침이 될 " +
977
+ "AGENT.md 파일을 write_file 로 생성해줘. 포함: ①프로젝트/폴더 개요 ②작업 시 지켜야 할 규칙(말투·제약·주의) " +
978
+ "③자주 할 작업 예시. 이미 AGENT.md 가 있으면 읽고 부족한 부분만 개선해."
979
+ );
980
+ continue;
981
+ }
982
+ // /status·/cost — 현재 상태 + 세션 누적 토큰 (Claude Code 컨벤션)
983
+ if (low === "/status" || low === "/cost") {
984
+ const u = loop.usage;
985
+ console.log(
986
+ panel(
987
+ [
988
+ `${c.grey("모델".padEnd(6))} ${c.bold(`${cfg.provider} · ${cfg.model}`)}`,
989
+ `${c.grey("폴더".padEnd(6))} ${cfg.workspacePath()}`,
990
+ `${c.grey("승인".padEnd(6))} ${cfg.approval_mode === "auto" ? c.yellow("자동 수락") : "수동(diff 확인)"} · 셸 ${cfg.allow_shell ? "허용" : "차단"}`,
991
+ `${c.grey("대화".padEnd(6))} 메시지 ${loop.messages.length}개 (/compact 로 압축 가능)`,
992
+ `${c.grey("토큰".padEnd(6))} 입력 ${u.input.toLocaleString()} · 출력 ${u.output.toLocaleString()} · 합계 ${c.bold(u.total.toLocaleString())} (호출 ${u.calls}회)`,
993
+ ],
994
+ { title: "📊 상태 (/status)", color: "cyan" }
995
+ )
996
+ );
997
+ continue;
998
+ }
999
+ // /models [검색어] — 모델 목록에서 번호로 선택(제공자별 실시간 조회)
1000
+ if (low === "/models" || low.startsWith("/models ")) {
1001
+ const query = user.split(/\s+/).slice(1).join(" ");
1002
+ const picked = await pickModel(ask, cfg, query);
1003
+ if (picked) {
1004
+ cfg.model = picked;
1005
+ loop.client = makeClient(cfg);
1006
+ saveConfig(cfg);
1007
+ console.log(c.green(`모델 변경 → ${picked}`) + c.dim(" (저장됨)"));
1008
+ } else {
1009
+ console.log(c.dim("변경하지 않았습니다."));
1010
+ }
1011
+ continue;
1012
+ }
1013
+ // /memory — 프로젝트 규칙 파일(AGENT.md) 보기 (Claude Code /memory)
1014
+ if (low === "/memory") {
1015
+ const candidates = ["AGENT.md", "AGENTS.md", "CLAUDE.md", "rules.md"].map((n) => path.join(cfg.workspacePath(), n));
1016
+ const found = candidates.find((p) => fs.existsSync(p));
1017
+ if (found) {
1018
+ console.log(panel(fs.readFileSync(found, "utf8").split("\n").slice(0, 40), { title: `🧠 프로젝트 규칙 — ${path.basename(found)}`, color: "cyan" }));
1019
+ console.log(c.dim(`전체 경로: ${found} (매 턴 시스템 프롬프트에 주입됩니다)`));
1020
+ } else {
1021
+ console.log(c.yellow("프로젝트 규칙 파일이 없습니다. ") + c.cyan("/init") + c.dim(" 을 실행하면 모델이 폴더를 파악해 AGENT.md 를 만들어줍니다."));
1022
+ }
1023
+ continue;
1024
+ }
1025
+ if (low === "/resume") {
1026
+ try {
1027
+ const saved = JSON.parse(fs.readFileSync(convPath, "utf8"));
1028
+ if (!Array.isArray(saved.messages) || !saved.messages.length) throw new Error("빈 세션");
1029
+ loop.messages = saved.messages;
1030
+ const when = new Date(saved.time).toLocaleString("ko-KR");
1031
+ console.log(c.green(`지난 대화를 이어갑니다 — ${when} · ${saved.model} · 메시지 ${saved.messages.length}개 복원`));
1032
+ console.log(c.dim("(새로 시작하려면 /reset)"));
1033
+ } catch {
1034
+ console.log(c.yellow("이어갈 지난 대화가 없습니다."));
1035
+ }
1036
+ continue;
1037
+ }
699
1038
  if (low === "/config") { printIntro(cfg); console.log(c.dim(`config.json: ${configPath()}`)); continue; }
700
1039
  if (low.startsWith("/workspace") || low.startsWith("/cd")) {
701
1040
  const arg = user.split(/\s+/).slice(1).join(" ").trim();
@@ -821,26 +1160,14 @@ export async function main(argv = []) {
821
1160
  continue;
822
1161
  }
823
1162
  console.log(c.dim(`(스킬 '/${name}' 실행)`));
824
- rule();
825
- try {
826
- await loop.run(renderSkill(skills[name], argStr));
827
- } catch (e) {
828
- console.log(c.red(`실행 오류: ${e?.message || e}`));
829
- }
830
- rule();
1163
+ await runTurn(renderSkill(skills[name], argStr));
831
1164
  } else {
832
1165
  console.log(c.yellow(`알 수 없는 명령/스킬: /${name} — ${c.cyan("/help")}, ${c.cyan("/skills")} 참고`));
833
1166
  }
834
1167
  continue;
835
1168
  }
836
1169
 
837
- rule();
838
- try {
839
- await loop.run(user);
840
- } catch (e) {
841
- console.log(c.red(`실행 오류: ${e?.message || e}`));
842
- }
843
- rule();
1170
+ await runTurn(user);
844
1171
  }
845
1172
 
846
1173
  rl.close();