agentforge-multi 0.1.1 → 0.1.3

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 (2) hide show
  1. package/agentforge +278 -254
  2. package/package.json +1 -1
package/agentforge CHANGED
@@ -7,7 +7,7 @@ Usage:
7
7
 
8
8
  Slash commands:
9
9
  /exit 종료
10
- /plan <목표> 계획 수립 후 실행
10
+ /resume 마지막 세션 재개
11
11
  <일반 텍스트> Worker에게 즉시 전달 (목표 설정)
12
12
  """
13
13
 
@@ -18,7 +18,6 @@ import re
18
18
  import select
19
19
  import subprocess
20
20
  import sys
21
- import tempfile
22
21
  import termios
23
22
  import textwrap
24
23
  import threading
@@ -27,6 +26,8 @@ import tty
27
26
  from collections import deque
28
27
  from pathlib import Path
29
28
 
29
+ import requests as _requests
30
+
30
31
  from rich.console import Console
31
32
  from rich.layout import Layout
32
33
  from rich.live import Live
@@ -45,6 +46,9 @@ from prompt_toolkit.formatted_text import HTML
45
46
  CODEX_BIN = Path.home() / ".npm-global" / "bin" / "codex"
46
47
  DEFAULT_MAX_ITER = 5000
47
48
  WORKER_BUF_LINES = 60
49
+ DEFAULT_WORKER_MODEL = "gpt-5.4"
50
+ DEFAULT_EVAL_MODEL = "gpt-5.1-codex-mini"
51
+ CHATGPT_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
48
52
  ANSI_RE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07')
49
53
  DECISION_RE = re.compile(r'^(DONE|IMPROVE|REDIRECT)(?::\s*(.*))?$', re.I | re.M)
50
54
  NOISE_RE = re.compile(r'^\s*([\-─═\s]+)?$')
@@ -52,12 +56,151 @@ NOISE_RE = re.compile(r'^\s*([\-─═\s]+)?$')
52
56
  console = Console()
53
57
  _last_session: dict | None = None # {goal, history, eval_history, workdir}
54
58
  _interrupt_event = threading.Event() # ESC 감지 플래그
55
- _current_proc: subprocess.Popen | None = None # 현재 실행 중인 subprocess
59
+
60
+ # ── ChatGPT backend API ────────────────────────────────────────────────────────
61
+
62
+ def _get_auth_headers() -> dict:
63
+ """~/.codex/auth.json 에서 Bearer 헤더 + ChatGPT-Account-Id 반환."""
64
+ auth_file = Path.home() / ".codex" / "auth.json"
65
+ data = json.loads(auth_file.read_text())
66
+ token = data["tokens"]["access_token"]
67
+ account_id = data["tokens"].get("account_id", "")
68
+ return {
69
+ "Authorization": f"Bearer {token}",
70
+ "Content-Type": "application/json",
71
+ "ChatGPT-Account-Id": account_id,
72
+ }
73
+
74
+
75
+ WORKER_TOOLS = [
76
+ {
77
+ "type": "function",
78
+ "name": "shell",
79
+ "description": "Execute a shell command. Use for running scripts, compiling, testing, installing packages, etc.",
80
+ "parameters": {
81
+ "type": "object",
82
+ "properties": {
83
+ "command": {"type": "string", "description": "Shell command to run"},
84
+ },
85
+ "required": ["command"],
86
+ },
87
+ "strict": False,
88
+ },
89
+ {
90
+ "type": "function",
91
+ "name": "read_file",
92
+ "description": "Read the full contents of a file",
93
+ "parameters": {
94
+ "type": "object",
95
+ "properties": {
96
+ "path": {"type": "string"},
97
+ },
98
+ "required": ["path"],
99
+ },
100
+ "strict": False,
101
+ },
102
+ {
103
+ "type": "function",
104
+ "name": "write_file",
105
+ "description": "Write content to a file (creates or overwrites)",
106
+ "parameters": {
107
+ "type": "object",
108
+ "properties": {
109
+ "path": {"type": "string"},
110
+ "content": {"type": "string"},
111
+ },
112
+ "required": ["path", "content"],
113
+ },
114
+ "strict": False,
115
+ },
116
+ {
117
+ "type": "function",
118
+ "name": "list_files",
119
+ "description": "List files and directories at a path",
120
+ "parameters": {
121
+ "type": "object",
122
+ "properties": {
123
+ "path": {"type": "string"},
124
+ },
125
+ "required": ["path"],
126
+ },
127
+ "strict": False,
128
+ },
129
+ ]
130
+
131
+
132
+ def _stream_response(payload: dict) -> list:
133
+ """
134
+ ChatGPT backend-api/codex/responses 스트리밍 호출.
135
+ SSE 이벤트를 파싱하여 (event_type, data_dict) 리스트 반환.
136
+ _interrupt_event가 set되면 조기 종료.
137
+ """
138
+ headers = _get_auth_headers()
139
+ events = []
140
+ try:
141
+ r = _requests.post(
142
+ CHATGPT_RESPONSES_URL, headers=headers,
143
+ json=payload, stream=True, timeout=300,
144
+ )
145
+ r.raise_for_status()
146
+ for line in r.iter_lines():
147
+ if _interrupt_event.is_set():
148
+ break
149
+ if not line:
150
+ continue
151
+ decoded = line.decode("utf-8", errors="replace")
152
+ if decoded.startswith("data: "):
153
+ try:
154
+ ev = json.loads(decoded[6:])
155
+ events.append(ev)
156
+ except Exception:
157
+ pass
158
+ except Exception as e:
159
+ events.append({"type": "_error", "message": str(e)})
160
+ return events
161
+
162
+
163
+ def _execute_tool(name: str, args: dict, workdir: str) -> str:
164
+ """Worker 도구 실행."""
165
+ try:
166
+ if name == "shell":
167
+ result = subprocess.run(
168
+ args["command"], shell=True,
169
+ capture_output=True, text=True, errors='replace',
170
+ cwd=workdir, timeout=120,
171
+ )
172
+ out = (result.stdout + result.stderr).strip()
173
+ return (out[:6000] if out else "(no output)") + (
174
+ f"\n[exit code: {result.returncode}]" if result.returncode != 0 else ""
175
+ )
176
+ elif name == "read_file":
177
+ path = Path(args["path"])
178
+ if not path.is_absolute():
179
+ path = Path(workdir) / path
180
+ return path.read_text(errors='replace')[:10000]
181
+ elif name == "write_file":
182
+ path = Path(args["path"])
183
+ if not path.is_absolute():
184
+ path = Path(workdir) / path
185
+ path.parent.mkdir(parents=True, exist_ok=True)
186
+ path.write_text(args["content"])
187
+ return f"Written: {path}"
188
+ elif name == "list_files":
189
+ path = Path(args["path"])
190
+ if not path.is_absolute():
191
+ path = Path(workdir) / path
192
+ items = sorted(path.iterdir())
193
+ return "\n".join(
194
+ ("dir " if p.is_dir() else "file ") + p.name for p in items
195
+ )
196
+ else:
197
+ return f"Unknown tool: {name}"
198
+ except Exception as e:
199
+ return f"Error: {e}"
56
200
 
57
201
  # ── Slash command autocomplete ────────────────────────────────────────────────
58
202
 
59
203
  SLASH_COMMANDS = [
60
- ("/plan", "계획을 수립한 뒤 Worker + Evaluator 루프 실행"),
61
204
  ("/resume", "마지막 세션을 이어서 실행"),
62
205
  ("/exit", "agentforge 종료"),
63
206
  ]
@@ -153,24 +296,6 @@ EVALUATOR_SYSTEM = textwrap.dedent("""\
153
296
  Do NOT write anything before the decision keyword.
154
297
  """).strip()
155
298
 
156
- PLAN_SYSTEM = textwrap.dedent("""\
157
- You are a planning agent. DO NOT write any code or modify any files.
158
- Your job is to create a detailed implementation plan in Korean.
159
-
160
- If anything is unclear, list your questions first in this format:
161
- 질문:
162
- 1. ...
163
- 2. ...
164
-
165
- If everything is clear, output the plan directly:
166
- 계획:
167
- - ...
168
- - ...
169
-
170
- Be specific and actionable. No code, only plan.
171
- """).strip()
172
-
173
-
174
299
  def build_worker_prompt(goal: str, history: list) -> str:
175
300
  lines = [f"GOAL: {goal}", ""]
176
301
  if not history:
@@ -208,18 +333,6 @@ def build_evaluator_prompt(goal: str, worker_output: str, iteration: int) -> str
208
333
  ])
209
334
 
210
335
 
211
- def build_plan_prompt(goal: str, qa_history: list) -> str:
212
- lines = [PLAN_SYSTEM, "", f"목표: {goal}"]
213
- if qa_history:
214
- lines.append("")
215
- lines.append("이전 질의응답:")
216
- for qa in qa_history:
217
- lines.append(f" Q: {qa['q']}")
218
- lines.append(f" A: {qa['a']}")
219
- lines.append("")
220
- lines.append("위 정보를 바탕으로 계획을 수립하거나 질문을 출력하라.")
221
- return "\n".join(lines)
222
-
223
336
 
224
337
  # ── Auth ──────────────────────────────────────────────────────────────────────
225
338
 
@@ -290,17 +403,21 @@ def cmd_auth_status():
290
403
  # ── ESC Listener ──────────────────────────────────────────────────────────────
291
404
 
292
405
  def _esc_listener(stop: threading.Event):
293
- """데몬 스레드: ESC 감지 _interrupt_event 설정."""
406
+ """데몬 스레드: /dev/tty 직접 열어 ESC 감지."""
294
407
  try:
295
- fd = sys.stdin.fileno()
296
- old = termios.tcgetattr(fd)
408
+ tty_fd = os.open('/dev/tty', os.O_RDONLY)
297
409
  except Exception:
298
410
  return
299
411
  try:
300
- tty.setcbreak(fd)
412
+ old = termios.tcgetattr(tty_fd)
413
+ new = list(old)
414
+ new[3] &= ~(termios.ICANON | termios.ECHO)
415
+ new[6][termios.VMIN] = 1
416
+ new[6][termios.VTIME] = 0
417
+ termios.tcsetattr(tty_fd, termios.TCSANOW, new)
301
418
  while not stop.is_set():
302
- if select.select([sys.stdin], [], [], 0.1)[0]:
303
- ch = os.read(fd, 1)
419
+ if select.select([tty_fd], [], [], 0.1)[0]:
420
+ ch = os.read(tty_fd, 1)
304
421
  if ch == b'\x1b':
305
422
  _interrupt_event.set()
306
423
  break
@@ -308,103 +425,132 @@ def _esc_listener(stop: threading.Event):
308
425
  pass
309
426
  finally:
310
427
  try:
311
- termios.tcsetattr(fd, termios.TCSADRAIN, old)
428
+ termios.tcsetattr(tty_fd, termios.TCSADRAIN, old)
429
+ os.close(tty_fd)
312
430
  except Exception:
313
431
  pass
314
432
 
315
433
 
316
434
  # ── Agent Runners ─────────────────────────────────────────────────────────────
317
435
 
318
- def _run_codex(cmd: list, workdir: str, buf: deque | None,
319
- status_ref: list | None) -> tuple[str, int]:
320
- """codex exec 실행. buf가 있으면 stdout 스트리밍. 최종 메시지 반환."""
321
- with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
322
- out_file = f.name
436
+ def run_worker(prompt: str, workdir: str, model: str | None,
437
+ buf: deque, status_ref: list) -> tuple[str, int]:
438
+ """Worker: ChatGPT backend Responses API 직접 호출 + 도구 실행 루프."""
439
+ model = model or DEFAULT_WORKER_MODEL
440
+ status_ref[0] = "running"
441
+
442
+ # 입력 히스토리 (user msg + function_call + function_call_output 누적)
443
+ input_history: list[dict] = [{"role": "user", "content": prompt}]
444
+ all_text_parts: list[str] = []
445
+ line_buf = ""
446
+
447
+ def _flush_line(text: str):
448
+ nonlocal line_buf
449
+ line_buf += text
450
+ while '\n' in line_buf:
451
+ ln, line_buf = line_buf.split('\n', 1)
452
+ if ln.strip() and not NOISE_RE.match(ln):
453
+ buf.append(colorize_line(ln))
454
+
455
+ for _round in range(30):
456
+ if _interrupt_event.is_set():
457
+ break
323
458
 
324
- full_cmd = cmd + ["-o", out_file]
325
- try:
326
- if buf is not None:
327
- global _current_proc
328
- if status_ref:
329
- status_ref[0] = "running"
330
- proc = subprocess.Popen(
331
- full_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
332
- text=True, errors='replace',
333
- )
334
- _current_proc = proc
335
- prev_line = ""
336
- for raw in proc.stdout:
337
- clean = strip_ansi(raw).rstrip()
338
- if clean and not NOISE_RE.match(clean) and clean != prev_line:
339
- buf.append(colorize_line(clean))
340
- prev_line = clean
341
- proc.wait()
342
- _current_proc = None
343
- rc = proc.returncode
344
- if status_ref:
345
- status_ref[0] = "done"
346
- else:
347
- proc = subprocess.run(
348
- full_cmd, capture_output=True, text=True, errors='replace',
349
- )
350
- rc = proc.returncode
351
- except Exception as e:
352
- if buf is not None:
353
- buf.append(f"[red][ERROR] {e}[/red]")
354
- if status_ref:
355
- status_ref[0] = "error"
356
- rc = 1
459
+ payload = {
460
+ "model": model,
461
+ "instructions": WORKER_SYSTEM,
462
+ "input": input_history,
463
+ "tools": WORKER_TOOLS,
464
+ "store": False,
465
+ "stream": True,
466
+ }
467
+
468
+ events = _stream_response(payload)
469
+
470
+ # 에러 처리
471
+ for ev in events:
472
+ if ev["type"] == "_error":
473
+ buf.append(f"[red]API 오류: {ev['message']}[/red]")
474
+ status_ref[0] = "error"
475
+ if line_buf.strip():
476
+ buf.append(colorize_line(line_buf))
477
+ return "\n".join(all_text_parts), 1
478
+
479
+ # 텍스트 + 도구 호출 수집
480
+ text_parts: list[str] = []
481
+ fc_items: list[dict] = [] # {call_id, name, arguments}
482
+
483
+ for ev in events:
484
+ t = ev["type"]
485
+ if t == "response.output_text.delta":
486
+ delta = ev.get("delta", "")
487
+ text_parts.append(delta)
488
+ _flush_line(delta)
489
+ elif t == "response.output_item.done" and ev.get("item", {}).get("type") == "function_call":
490
+ item = ev["item"]
491
+ fc_items.append({
492
+ "call_id": item["call_id"],
493
+ "name": item["name"],
494
+ "arguments": item["arguments"],
495
+ })
496
+
497
+ round_text = "".join(text_parts)
498
+ if round_text:
499
+ all_text_parts.append(round_text)
500
+
501
+ # 도구 호출 없으면 종료
502
+ if not fc_items:
503
+ break
357
504
 
358
- last_msg = ""
359
- try:
360
- last_msg = strip_ansi(Path(out_file).read_text()).strip()
361
- except Exception:
362
- if buf:
363
- last_msg = strip_ansi("\n".join(list(buf)[-10:]))
364
- finally:
365
- try:
366
- Path(out_file).unlink()
367
- except Exception:
368
- pass
505
+ # 도구 실행 및 히스토리에 추가
506
+ for fc in fc_items:
507
+ call_id = fc["call_id"]
508
+ name = fc["name"]
509
+ raw_args = fc["arguments"]
510
+ try:
511
+ args = json.loads(raw_args)
512
+ except Exception:
513
+ args = {}
369
514
 
370
- return last_msg, rc
515
+ arg_preview = raw_args[:80]
516
+ buf.append(f"[cyan]▶ {name}({arg_preview})[/cyan]")
517
+ result = _execute_tool(name, args, workdir)
518
+ short = result[:300].replace('\n', ' ')
519
+ buf.append(f"[dim]{short}[/dim]")
371
520
 
521
+ # Responses API 형식 히스토리
522
+ input_history.append({"type": "function_call", "call_id": call_id,
523
+ "name": name, "arguments": raw_args})
524
+ input_history.append({"type": "function_call_output", "call_id": call_id,
525
+ "output": result})
372
526
 
373
- def run_worker(prompt: str, workdir: str, model: str | None,
374
- buf: deque, status_ref: list) -> tuple[str, int]:
375
- cmd = [
376
- str(CODEX_BIN), "exec",
377
- "--full-auto", "--skip-git-repo-check", "--color", "never",
378
- "-C", workdir,
379
- ]
380
- if model:
381
- cmd += ["-m", model]
382
- cmd.append(prompt)
383
- return _run_codex(cmd, workdir, buf, status_ref)
527
+ # 잔여 line_buf flush
528
+ if line_buf.strip():
529
+ buf.append(colorize_line(line_buf))
530
+
531
+ status_ref[0] = "done"
532
+ return "\n".join(all_text_parts), 0
384
533
 
385
534
 
386
535
  def run_evaluator(prompt: str, workdir: str, model: str | None) -> tuple[str, int]:
387
- cmd = [
388
- str(CODEX_BIN), "exec",
389
- "-s", "read-only", "--skip-git-repo-check", "--color", "never",
390
- "-C", workdir,
391
- ]
392
- if model:
393
- cmd += ["-m", model]
394
- cmd.append(prompt)
395
- return _run_codex(cmd, workdir, None, None)
396
-
397
-
398
- def run_plan_agent(prompt: str, workdir: str, model: str | None) -> tuple[str, int]:
399
- cmd = [
400
- str(CODEX_BIN), "exec",
401
- "-s", "read-only", "--skip-git-repo-check", "--color", "never",
402
- "-C", workdir,
403
- ]
404
- if model:
405
- cmd += ["-m", model]
406
- cmd.append(prompt)
407
- return _run_codex(cmd, workdir, None, None)
536
+ """Evaluator: ChatGPT backend Responses API, 도구 없이 텍스트만."""
537
+ model = model or DEFAULT_EVAL_MODEL
538
+ payload = {
539
+ "model": model,
540
+ "instructions": EVALUATOR_SYSTEM,
541
+ "input": [{"role": "user", "content": prompt}],
542
+ "store": False,
543
+ "stream": True,
544
+ }
545
+ events = _stream_response(payload)
546
+ for ev in events:
547
+ if ev["type"] == "_error":
548
+ return f"[Evaluator error] {ev['message']}", 1
549
+ text = "".join(
550
+ ev.get("delta", "") for ev in events
551
+ if ev["type"] == "response.output_text.delta"
552
+ )
553
+ return text, 0
408
554
 
409
555
 
410
556
  def parse_decision(text: str) -> tuple[str, str]:
@@ -420,11 +566,9 @@ def parse_decision(text: str) -> tuple[str, str]:
420
566
  # ── TUI ───────────────────────────────────────────────────────────────────────
421
567
 
422
568
  def make_layout() -> Layout:
423
- try:
424
- term_h = os.get_terminal_size().lines
425
- except OSError:
426
- term_h = 40
427
- body_size = max(10, term_h - 5) # header(3) + 여백(2)
569
+ import shutil
570
+ term_h = shutil.get_terminal_size(fallback=(80, 40)).lines
571
+ body_size = max(10, term_h - 8) # header(3) + 여백(5)
428
572
 
429
573
  layout = Layout()
430
574
  layout.split(
@@ -441,10 +585,9 @@ def make_layout() -> Layout:
441
585
  def render_header(goal: str, iteration: int, max_iter: int, phase: str) -> Panel:
442
586
  g = goal[:72] + "..." if len(goal) > 72 else goal
443
587
  status = {
444
- "idle": "[dim]명령 대기 중 /plan <목표> 또는 <목표> 입력[/dim]",
588
+ "idle": "[dim]명령 대기 중 목표를 입력하거나 /resume /exit[/dim]",
445
589
  "worker": "⚙ [bold yellow on black] WORKER 실행 중 [/bold yellow on black] [dim]Evaluator 대기[/dim]",
446
590
  "evaluator": "[dim]Worker 완료[/dim] → ◈ [bold magenta on black] EVALUATOR 평가 중 [/bold magenta on black]",
447
- "planning": "◑ [bold cyan on black] PLAN AGENT 실행 중 [/bold cyan on black]",
448
591
  "done": "[bold green]✓ 완료[/bold green]",
449
592
  "max": "[bold red]⚠ 최대 반복 도달[/bold red]",
450
593
  }.get(phase, phase)
@@ -530,8 +673,6 @@ def render_evaluator_panel(eval_history: list, iteration: int,
530
673
  if phase == "evaluator":
531
674
  lines.append(f"[magenta bold]── Iter {iteration} (평가 중) ──────────────[/magenta bold]")
532
675
  lines.append("[magenta blink]평가 중...[/magenta blink]")
533
- elif phase == "planning":
534
- lines.append("[cyan blink]계획 수립 중...[/cyan blink]")
535
676
  border = "green" if done else "magenta"
536
677
  is_active = (phase == "evaluator")
537
678
  if done:
@@ -548,82 +689,6 @@ def render_evaluator_panel(eval_history: list, iteration: int,
548
689
  )
549
690
 
550
691
 
551
- # ── Plan Mode ─────────────────────────────────────────────────────────────────
552
-
553
- def run_plan_mode(raw_goal: str, workdir: str, model: str | None,
554
- layout: Layout, live: Live) -> str | None:
555
- """
556
- /plan 모드: Plan Agent와 대화 후 최종 계획(목표 문자열)을 반환.
557
- 취소 시 None 반환.
558
- """
559
- goal = raw_goal.strip()
560
- if not goal:
561
- live.stop()
562
- try:
563
- goal = input("계획할 목표를 입력하세요: ").strip()
564
- except (EOFError, KeyboardInterrupt):
565
- return None
566
- if not goal:
567
- return None
568
-
569
- qa_history = []
570
-
571
- while True:
572
- # Plan Agent 실행
573
- layout["header"].update(render_header(goal, 0, 0, "planning"))
574
- layout["worker"].update(render_worker_panel(deque(), 0, "idle", [], False))
575
- layout["evaluator"].update(render_evaluator_panel([], 0, "planning", False))
576
- live.start()
577
- time.sleep(0.2)
578
-
579
- plan_prompt = build_plan_prompt(goal, qa_history)
580
- plan_output, _ = run_plan_agent(plan_prompt, workdir, model)
581
-
582
- live.stop()
583
- console.print()
584
-
585
- # 질문이 있는지 확인
586
- has_questions = '질문:' in plan_output or re.search(r'^\d+\.\s', plan_output, re.M)
587
- has_plan = '계획:' in plan_output or re.search(r'^[-•]\s', plan_output, re.M)
588
-
589
- console.print(Rule("[cyan]Plan Agent[/cyan]"))
590
- console.print(plan_output)
591
- console.print()
592
-
593
- if has_questions and not has_plan:
594
- # 질의응답
595
- console.print("[dim]질문에 답변하세요 (취소: /cancel):[/dim]")
596
- try:
597
- answer = input("> ").strip()
598
- except (EOFError, KeyboardInterrupt):
599
- return None
600
- if answer.lower() in ('/cancel', '/exit'):
601
- return None
602
- qa_history.append({'q': plan_output, 'a': answer})
603
- continue # 다시 Plan Agent 실행
604
-
605
- # 계획 수립 완료 → accept
606
- console.print("[dim]이 계획으로 진행하시겠습니까? (y: 실행 / n: 다시 작성 / /cancel: 취소)[/dim]")
607
- try:
608
- ans = input("accept (y/n) > ").strip().lower()
609
- except (EOFError, KeyboardInterrupt):
610
- return None
611
- if ans == 'y':
612
- # 계획 내용을 목표로 삼아 반환
613
- final_goal = f"{goal}\n\n[계획]\n{plan_output}"
614
- return final_goal
615
- elif ans in ('/cancel', '/exit'):
616
- return None
617
- # n → 다시 처음부터
618
- qa_history.clear()
619
- try:
620
- new_goal = input("새 목표를 입력하거나 Enter로 기존 목표 유지: ").strip()
621
- except (EOFError, KeyboardInterrupt):
622
- return None
623
- if new_goal:
624
- goal = new_goal
625
-
626
-
627
692
  # ── Agent Loop ────────────────────────────────────────────────────────────────
628
693
 
629
694
  def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
@@ -678,9 +743,7 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
678
743
  t.start()
679
744
  while t.is_alive():
680
745
  if _interrupt_event.is_set():
681
- if _current_proc:
682
- _current_proc.kill()
683
- break
746
+ break # worker 스레드는 _interrupt_event 확인 후 자체 중단
684
747
  refresh("worker", iteration)
685
748
  time.sleep(0.1)
686
749
  t.join()
@@ -842,10 +905,6 @@ def main():
842
905
  auth_parser.print_help()
843
906
  return
844
907
 
845
- if not CODEX_BIN.exists():
846
- console.print(f"[bold red]Error:[/bold red] codex not found at {CODEX_BIN}")
847
- sys.exit(1)
848
-
849
908
  workdir = str(Path(args.dir).resolve())
850
909
  max_iter = args.max_iterations
851
910
 
@@ -854,11 +913,8 @@ def main():
854
913
  layout["header"].update(render_header("", 0, max_iter, "idle"))
855
914
  layout["worker"].update(render_worker_panel(deque(), 0, "idle", [], False))
856
915
  layout["evaluator"].update(render_evaluator_panel([], 0, "idle", False))
857
-
858
- live = Live(layout, refresh_per_second=8, screen=False)
859
- live.start()
860
- time.sleep(0.3)
861
- live.stop()
916
+ with Live(layout, refresh_per_second=8, screen=False) as live:
917
+ time.sleep(0.3)
862
918
 
863
919
  # ── REPL 루프 ─────────────────────────────────────────────────────
864
920
  status = auth_status()
@@ -882,8 +938,7 @@ def main():
882
938
  "agentforge auth login 으로 나중에 로그인할 수 있습니다.[/dim]"
883
939
  )
884
940
 
885
- console.print()
886
- console.print("[dim]명령을 입력하세요. /plan <목표> | /exit[/dim]")
941
+ console.print("[dim]명령을 입력하세요. /resume | /exit[/dim]")
887
942
 
888
943
  _completer = SlashCompleter()
889
944
 
@@ -912,37 +967,6 @@ def main():
912
967
  console.print("[dim]종료합니다.[/dim]")
913
968
  break
914
969
 
915
- elif cmd_name == 'plan':
916
- # Plan 모드
917
- layout["header"].update(render_header(cmd_arg or "", 0, max_iter, "idle"))
918
- layout["worker"].update(render_worker_panel(deque(), 0, "idle", [], False))
919
- layout["evaluator"].update(render_evaluator_panel([], 0, "idle", False))
920
-
921
- final_goal = run_plan_mode(cmd_arg, workdir, args.worker_model, layout, live)
922
- if final_goal is None:
923
- console.print("[dim]계획 취소됨.[/dim]")
924
- continue
925
- console.print()
926
- console.print("[cyan]계획 확정. Worker + Evaluator 루프를 시작합니다...[/cyan]")
927
- time.sleep(0.5)
928
- # 새 layout/live 인스턴스로 루프 실행
929
- layout2 = make_layout()
930
- layout2["header"].update(render_header(final_goal, 0, max_iter, "idle"))
931
- layout2["worker"].update(render_worker_panel(deque(), 0, "idle", [], False))
932
- layout2["evaluator"].update(render_evaluator_panel([], 0, "idle", False))
933
- live2 = Live(layout2, refresh_per_second=8, screen=False)
934
- outcome = run_agent_loop(
935
- final_goal, workdir, args.worker_model, args.eval_model,
936
- max_iter, layout2, live2,
937
- )
938
- goal = final_goal
939
- if outcome == 'interrupted':
940
- goal = _handle_interrupt(goal, workdir, args, max_iter)
941
- elif outcome == 'max':
942
- console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
943
- if outcome != 'interrupted':
944
- console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
945
-
946
970
  elif cmd_name == 'resume':
947
971
  if not _last_session:
948
972
  console.print("[yellow]재개할 세션이 없습니다. 먼저 목표를 실행하세요.[/yellow]")
@@ -975,7 +999,7 @@ def main():
975
999
 
976
1000
  else:
977
1001
  console.print(f"[red]알 수 없는 커맨드: /{cmd_name}[/red]")
978
- console.print("[dim]사용 가능: /plan <목표> /resume /exit[/dim]")
1002
+ console.print("[dim]사용 가능: /resume /exit[/dim]")
979
1003
 
980
1004
  else:
981
1005
  # 일반 텍스트 → 바로 Worker에게 목표로 전달
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentforge-multi",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Multi-agent CLI: Worker + Evaluator agents collaborate in a loop to achieve your goal",
5
5
  "keywords": [
6
6
  "ai",