agentforge-multi 0.1.0 → 0.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.
package/README.ko.md CHANGED
@@ -81,13 +81,23 @@ DONE 판정이 나거나 최대 반복 횟수에 도달할 때까지 루프가
81
81
 
82
82
  ## 설치
83
83
 
84
+ ### npm으로 설치 (권장)
85
+
86
+ ```bash
87
+ npm install -g agentforge-multi
88
+ ```
89
+
90
+ `rich`, `prompt_toolkit` 패키지는 postinstall 스크립트가 자동으로 설치합니다.
91
+
92
+ ### git으로 설치
93
+
84
94
  ```bash
85
95
  git clone https://github.com/<your-username>/AgentForge.git
86
96
  cd AgentForge
87
97
  bash install.sh
88
98
  ```
89
99
 
90
- 또는 수동 설치:
100
+ ### 수동 설치
91
101
 
92
102
  ```bash
93
103
  cp agentforge ~/.local/bin/agentforge
package/README.md CHANGED
@@ -81,13 +81,23 @@ The loop continues until the Evaluator decides `DONE` or the iteration limit is
81
81
 
82
82
  ## Installation
83
83
 
84
+ ### via npm (recommended)
85
+
86
+ ```bash
87
+ npm install -g agentforge-multi
88
+ ```
89
+
90
+ Dependencies (`rich`, `prompt_toolkit`) are installed automatically via postinstall.
91
+
92
+ ### via git
93
+
84
94
  ```bash
85
95
  git clone https://github.com/<your-username>/AgentForge.git
86
96
  cd AgentForge
87
97
  bash install.sh
88
98
  ```
89
99
 
90
- Or manually:
100
+ ### manually
91
101
 
92
102
  ```bash
93
103
  cp agentforge ~/.local/bin/agentforge
package/agentforge CHANGED
@@ -12,13 +12,18 @@ Slash commands:
12
12
  """
13
13
 
14
14
  import argparse
15
+ import json
16
+ import os
15
17
  import re
18
+ import select
16
19
  import subprocess
17
20
  import sys
18
21
  import tempfile
22
+ import termios
19
23
  import textwrap
20
24
  import threading
21
25
  import time
26
+ import tty
22
27
  from collections import deque
23
28
  from pathlib import Path
24
29
 
@@ -45,12 +50,16 @@ DECISION_RE = re.compile(r'^(DONE|IMPROVE|REDIRECT)(?::\s*(.*))?$', re.I | re.M)
45
50
  NOISE_RE = re.compile(r'^\s*([\-─═\s]+)?$')
46
51
 
47
52
  console = Console()
53
+ _last_session: dict | None = None # {goal, history, eval_history, workdir}
54
+ _interrupt_event = threading.Event() # ESC 감지 플래그
55
+ _current_proc: subprocess.Popen | None = None # 현재 실행 중인 subprocess
48
56
 
49
57
  # ── Slash command autocomplete ────────────────────────────────────────────────
50
58
 
51
59
  SLASH_COMMANDS = [
52
- ("/plan", "계획을 수립한 뒤 Worker + Evaluator 루프 실행"),
53
- ("/exit", "agentforge 종료"),
60
+ ("/plan", "계획을 수립한 뒤 Worker + Evaluator 루프 실행"),
61
+ ("/resume", "마지막 세션을 이어서 실행"),
62
+ ("/exit", "agentforge 종료"),
54
63
  ]
55
64
 
56
65
  class SlashCompleter(Completer):
@@ -212,6 +221,98 @@ def build_plan_prompt(goal: str, qa_history: list) -> str:
212
221
  return "\n".join(lines)
213
222
 
214
223
 
224
+ # ── Auth ──────────────────────────────────────────────────────────────────────
225
+
226
+ def auth_status() -> dict | None:
227
+ auth_file = Path.home() / ".codex" / "auth.json"
228
+ if not auth_file.exists():
229
+ return None
230
+ try:
231
+ data = json.loads(auth_file.read_text())
232
+ except Exception:
233
+ return None
234
+ return {
235
+ "mode": data.get("auth_mode", "unknown"),
236
+ "account_id": data.get("tokens", {}).get("account_id"),
237
+ "last_refresh": data.get("last_refresh"),
238
+ "has_token": bool(data.get("tokens", {}).get("access_token")),
239
+ }
240
+
241
+
242
+ def cmd_auth_login(device: bool = False):
243
+ console.print(Rule("[cyan]agentforge — 로그인[/cyan]"))
244
+ if not device and not sys.stdout.isatty():
245
+ console.print("[yellow]SSH 환경 감지됨. --device 플래그를 권장합니다.[/yellow]\n")
246
+ cmd = [str(CODEX_BIN), "login"]
247
+ if device:
248
+ cmd.append("--device-auth")
249
+ result = subprocess.run(cmd)
250
+ if result.returncode == 0:
251
+ status = auth_status()
252
+ console.print(f"\n[green]✓ 로그인 완료[/green]")
253
+ if status and status["account_id"]:
254
+ console.print(f" 계정: {status['account_id']}")
255
+ else:
256
+ console.print("[red]✗ 로그인 실패[/red]")
257
+ sys.exit(1)
258
+
259
+
260
+ def cmd_auth_logout():
261
+ console.print(Rule("[cyan]agentforge — 로그아웃[/cyan]"))
262
+ result = subprocess.run([str(CODEX_BIN), "logout"])
263
+ if result.returncode == 0:
264
+ console.print("[green]✓ 로그아웃 완료[/green]")
265
+ else:
266
+ console.print("[red]✗ 로그아웃 실패[/red]")
267
+
268
+
269
+ def cmd_auth_status():
270
+ status = auth_status()
271
+ if not status or not status["has_token"]:
272
+ console.print(Panel(
273
+ "[red]로그인되지 않음[/red]\n\n"
274
+ "로그인: [cyan]agentforge auth login[/cyan]\n"
275
+ "SSH 환경: [cyan]agentforge auth login --device[/cyan]",
276
+ title="인증 상태", border_style="red",
277
+ ))
278
+ return
279
+ mode_label = {"chatgpt": "ChatGPT (Plus / Pro)"}.get(
280
+ status["mode"], status["mode"])
281
+ console.print(Panel(
282
+ f"[green]✓ 로그인됨[/green]\n\n"
283
+ f"방식: [cyan]{mode_label}[/cyan]\n"
284
+ f"계정: {status['account_id'] or '(알 수 없음)'}\n"
285
+ f"갱신: {status['last_refresh'] or '(알 수 없음)'}",
286
+ title="인증 상태", border_style="green",
287
+ ))
288
+
289
+
290
+ # ── ESC Listener ──────────────────────────────────────────────────────────────
291
+
292
+ def _esc_listener(stop: threading.Event):
293
+ """데몬 스레드: ESC 키 감지 시 _interrupt_event 설정."""
294
+ try:
295
+ fd = sys.stdin.fileno()
296
+ old = termios.tcgetattr(fd)
297
+ except Exception:
298
+ return
299
+ try:
300
+ tty.setcbreak(fd)
301
+ while not stop.is_set():
302
+ if select.select([sys.stdin], [], [], 0.1)[0]:
303
+ ch = os.read(fd, 1)
304
+ if ch == b'\x1b':
305
+ _interrupt_event.set()
306
+ break
307
+ except Exception:
308
+ pass
309
+ finally:
310
+ try:
311
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
312
+ except Exception:
313
+ pass
314
+
315
+
215
316
  # ── Agent Runners ─────────────────────────────────────────────────────────────
216
317
 
217
318
  def _run_codex(cmd: list, workdir: str, buf: deque | None,
@@ -223,12 +324,14 @@ def _run_codex(cmd: list, workdir: str, buf: deque | None,
223
324
  full_cmd = cmd + ["-o", out_file]
224
325
  try:
225
326
  if buf is not None:
327
+ global _current_proc
226
328
  if status_ref:
227
329
  status_ref[0] = "running"
228
330
  proc = subprocess.Popen(
229
331
  full_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
230
332
  text=True, errors='replace',
231
333
  )
334
+ _current_proc = proc
232
335
  prev_line = ""
233
336
  for raw in proc.stdout:
234
337
  clean = strip_ansi(raw).rstrip()
@@ -236,6 +339,7 @@ def _run_codex(cmd: list, workdir: str, buf: deque | None,
236
339
  buf.append(colorize_line(clean))
237
340
  prev_line = clean
238
341
  proc.wait()
342
+ _current_proc = None
239
343
  rc = proc.returncode
240
344
  if status_ref:
241
345
  status_ref[0] = "done"
@@ -316,10 +420,16 @@ def parse_decision(text: str) -> tuple[str, str]:
316
420
  # ── TUI ───────────────────────────────────────────────────────────────────────
317
421
 
318
422
  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)
428
+
319
429
  layout = Layout()
320
430
  layout.split(
321
431
  Layout(name="header", size=3),
322
- Layout(name="body"),
432
+ Layout(name="body", size=body_size),
323
433
  )
324
434
  layout["body"].split_row(
325
435
  Layout(name="worker"),
@@ -332,37 +442,57 @@ def render_header(goal: str, iteration: int, max_iter: int, phase: str) -> Panel
332
442
  g = goal[:72] + "..." if len(goal) > 72 else goal
333
443
  status = {
334
444
  "idle": "[dim]명령 대기 중 /plan <목표> 또는 <목표> 입력[/dim]",
335
- "worker": "[yellow] Worker 실행 중...[/yellow]",
336
- "evaluator": "[magenta]◈ Evaluator 평가 중...[/magenta]",
337
- "planning": "[cyan] Plan Agent 실행 중...[/cyan]",
338
- "done": "[bold green]✓ 완료[/bold green]",
339
- "max": "[bold red]최대 반복 도달[/bold red]",
445
+ "worker": "[bold yellow on black] WORKER 실행 [/bold yellow on black] [dim]Evaluator 대기[/dim]",
446
+ "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
+ "done": "[bold green]✓ 완료[/bold green]",
449
+ "max": "[bold red]최대 반복 도달[/bold red]",
340
450
  }.get(phase, phase)
341
451
  iter_str = f" Iter [bold]{iteration}[/bold]/{max_iter}" if iteration > 0 else ""
342
452
  content = f"[bold cyan]Goal:[/bold cyan] {g}\n{status}{iter_str}"
343
453
  return Panel(content, border_style="cyan", box=box.HEAVY_HEAD)
344
454
 
345
455
 
456
+ _DECISION_BADGE = {
457
+ "DONE": "[green]✓[/green]",
458
+ "IMPROVE": "[yellow]~[/yellow]",
459
+ "REDIRECT": "[red]↩[/red]",
460
+ }
461
+
346
462
  def render_worker_panel(buf: deque, iteration: int, status: str,
347
463
  history: list, done: bool) -> Panel:
348
464
  lines = []
349
- for h in history[-3:]: # 최근 3 iteration만 dim으로 표시
350
- lines.append(f"[dim]── Iter {h['iter']} {'─'*30}[/dim]")
351
- for l in h.get('worker_lines', [])[-5:]:
352
- lines.append(f"[dim]{l}[/dim]")
465
+ # 오래된 기록 (직전 제외): summary + 배지
466
+ for h in history[:-1]:
467
+ badge = _DECISION_BADGE.get(h['decision'], '[dim]?[/dim]')
468
+ summary = (h['worker_summary'] or '')[:70]
469
+ lines.append(f"[dim]Iter {h['iter']}[/dim] {badge} [dim]{summary}[/dim]")
470
+ # 직전 기록: worker_lines 최대 8줄 (밝게)
471
+ if history:
472
+ h = history[-1]
473
+ lines.append(f"[bold]── Iter {h['iter']} ──────────────────────────────[/bold]")
474
+ for l in h.get('worker_lines', [])[-8:]:
475
+ lines.append(f"[white]{l}[/white]")
476
+ # 현재 스트리밍
353
477
  if buf or status == "running":
354
478
  if history:
355
- lines.append(f"[yellow]── Iter {iteration} {'─'*30}[/yellow]")
479
+ lines.append(f"[yellow bold]── Iter {iteration} (실행 중) ──────────────[/yellow bold]")
356
480
  for line in list(buf):
357
481
  lines.append(line)
358
482
  if status == "running":
359
483
  lines.append("[yellow blink]▌[/yellow blink]")
360
484
  border = "green" if done else "yellow"
361
- icon = "✓" if done else ""
485
+ is_active = (status == "running")
486
+ if done:
487
+ title = "[green bold]✓ WORKER AGENT[/green bold]"
488
+ elif is_active:
489
+ title = "[yellow bold]⚙ WORKER AGENT ◀ 실행 중[/yellow bold]"
490
+ else:
491
+ title = "[dim]⚙ WORKER AGENT[/dim]"
362
492
  content = "\n".join(lines) if lines else "[dim]명령을 입력하면 작업을 시작합니다.[/dim]"
363
493
  return Panel(
364
494
  Text.from_markup(content),
365
- title=f"[{border}]{icon} WORKER AGENT[/{border}]",
495
+ title=title,
366
496
  border_style=border, box=box.ROUNDED, padding=(0, 1),
367
497
  )
368
498
 
@@ -370,37 +500,50 @@ def render_worker_panel(buf: deque, iteration: int, status: str,
370
500
  def render_evaluator_panel(eval_history: list, iteration: int,
371
501
  phase: str, done: bool) -> Panel:
372
502
  lines = []
373
- for e in eval_history:
503
+ # 오래된 기록 (최신 제외): 배지 + 요약 한 줄
504
+ for e in eval_history[:-1]:
505
+ d, fb = e['decision'], e['feedback']
506
+ badge = {"DONE": "[green]✓ DONE[/green]",
507
+ "IMPROVE": "[yellow]IMPROVE[/yellow]",
508
+ "REDIRECT": "[red]REDIRECT[/red]"}.get(d, d)
509
+ short_fb = fb[:65] + ('…' if len(fb) > 65 else '')
510
+ lines.append(f"[dim]Iter {e['iter']}[/dim] {badge} [dim]{short_fb}[/dim]")
511
+ # 최신 기록: 전체 피드백 표시
512
+ if eval_history:
513
+ e = eval_history[-1]
374
514
  d, fb, fm = e['decision'], e['feedback'], e.get('full_msg', '')
375
- lines.append(f"[dim]── Iter {e['iter']} {'─'*28}[/dim]")
515
+ lines.append(f"[bold]── Iter {e['iter']} ──────────────────────────────[/bold]")
376
516
  if d == 'DONE':
377
517
  lines.append("[bold green]✓ DONE[/bold green]")
378
- # 한국어 요약 섹션 추출
379
518
  for section in ['판단 이유', '결과물 위치', '결과 요약']:
380
519
  m = re.search(rf'{section}:\s*(.+?)(?=\n판단|결과물|결과 요약|$)', fm, re.S)
381
520
  if m:
382
- val = m.group(1).strip()[:120]
383
- lines.append(f"[dim]{section}:[/dim] [white]{val}[/white]")
521
+ val = m.group(1).strip()[:200]
522
+ lines.append(f"[cyan]{section}:[/cyan] [white]{val}[/white]")
384
523
  elif d == 'IMPROVE':
385
- lines.append("[bold yellow]IMPROVE[/bold yellow]")
386
- for l in textwrap.wrap(fb, 42):
387
- lines.append(f"[yellow]{l}[/yellow]")
524
+ lines.append("[bold yellow]IMPROVE[/bold yellow]")
525
+ lines.append(f"[yellow]{fb}[/yellow]")
388
526
  elif d == 'REDIRECT':
389
- lines.append("[bold red]REDIRECT[/bold red]")
390
- for l in textwrap.wrap(fb, 42):
391
- lines.append(f"[red]{l}[/red]")
527
+ lines.append("[bold red]REDIRECT[/bold red]")
528
+ lines.append(f"[red]{fb}[/red]")
392
529
  lines.append("")
393
530
  if phase == "evaluator":
394
- lines.append(f"[magenta]── Iter {iteration} {'─'*28}[/magenta]")
531
+ lines.append(f"[magenta bold]── Iter {iteration} (평가 중) ──────────────[/magenta bold]")
395
532
  lines.append("[magenta blink]평가 중...[/magenta blink]")
396
533
  elif phase == "planning":
397
534
  lines.append("[cyan blink]계획 수립 중...[/cyan blink]")
398
535
  border = "green" if done else "magenta"
399
- icon = "✓" if done else ""
536
+ is_active = (phase == "evaluator")
537
+ if done:
538
+ title = "[green bold]✓ EVALUATOR AGENT[/green bold]"
539
+ elif is_active:
540
+ title = "[magenta bold]◈ EVALUATOR AGENT ◀ 평가 중[/magenta bold]"
541
+ else:
542
+ title = "[dim]◈ EVALUATOR AGENT[/dim]"
400
543
  content = "\n".join(lines) if lines else "[dim]Worker 완료 후 평가를 시작합니다.[/dim]"
401
544
  return Panel(
402
545
  Text.from_markup(content),
403
- title=f"[{border}]{icon} EVALUATOR AGENT[/{border}]",
546
+ title=title,
404
547
  border_style=border, box=box.ROUNDED, padding=(0, 1),
405
548
  )
406
549
 
@@ -485,17 +628,30 @@ def run_plan_mode(raw_goal: str, workdir: str, model: str | None,
485
628
 
486
629
  def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
487
630
  eval_model: str | None, max_iter: int,
488
- layout: Layout, live: Live) -> str:
631
+ layout: Layout, live: Live,
632
+ initial_history: list | None = None,
633
+ initial_eval_history: list | None = None) -> str:
489
634
  """
490
635
  Worker + Evaluator 반복 루프.
491
636
  반환: 'done' | 'max'
492
637
  """
493
- history = []
494
- eval_history = []
638
+ global _last_session
639
+ history = list(initial_history or [])
640
+ eval_history = list(initial_eval_history or [])
495
641
  worker_buf = deque(maxlen=WORKER_BUF_LINES)
496
642
  worker_status = ["idle"]
497
643
  done = False
498
644
 
645
+ # ESC 리스너 시작
646
+ _interrupt_event.clear()
647
+ _esc_stop = threading.Event()
648
+ esc_t = threading.Thread(target=_esc_listener, args=(_esc_stop,), daemon=True)
649
+ esc_t.start()
650
+
651
+ def _finish(result: str) -> str:
652
+ _esc_stop.set()
653
+ return result
654
+
499
655
  def refresh(phase: str, iteration: int):
500
656
  layout["header"].update(render_header(goal, iteration, max_iter, phase))
501
657
  layout["worker"].update(render_worker_panel(
@@ -521,11 +677,22 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
521
677
  t = threading.Thread(target=_worker, daemon=True)
522
678
  t.start()
523
679
  while t.is_alive():
680
+ if _interrupt_event.is_set():
681
+ if _current_proc:
682
+ _current_proc.kill()
683
+ break
524
684
  refresh("worker", iteration)
525
685
  time.sleep(0.1)
526
686
  t.join()
527
687
  refresh("worker", iteration)
528
688
 
689
+ # ESC로 중단된 경우
690
+ if _interrupt_event.is_set():
691
+ live.stop()
692
+ _last_session = {"goal": goal, "history": history,
693
+ "eval_history": eval_history, "workdir": workdir}
694
+ return _finish('interrupted')
695
+
529
696
  last_msg, _ = worker_result
530
697
  worker_summary = (last_msg or "").replace('\n', ' ')[:300]
531
698
  history.append({
@@ -537,6 +704,12 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
537
704
  })
538
705
 
539
706
  # ── Evaluator ────────────────────────────────────────────────────
707
+ if _interrupt_event.is_set():
708
+ live.stop()
709
+ _last_session = {"goal": goal, "history": history,
710
+ "eval_history": eval_history, "workdir": workdir}
711
+ return _finish('interrupted')
712
+
540
713
  refresh("evaluator", iteration)
541
714
  eval_prompt = build_evaluator_prompt(goal, last_msg or "", iteration)
542
715
  eval_msg, _ = run_evaluator(eval_prompt, workdir, eval_model)
@@ -556,6 +729,10 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
556
729
  refresh("done", iteration)
557
730
  time.sleep(0.8)
558
731
  live.stop()
732
+ _last_session = {
733
+ "goal": goal, "history": history,
734
+ "eval_history": eval_history, "workdir": workdir,
735
+ }
559
736
 
560
737
  # 완료 요약 출력
561
738
  console.print()
@@ -569,10 +746,57 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
569
746
  for line in val.splitlines():
570
747
  console.print(f" [{color}]{line}[/{color}]")
571
748
  console.print()
572
- return 'done'
749
+ return _finish('done')
573
750
 
574
751
  live.stop()
575
- return 'max'
752
+ _last_session = {
753
+ "goal": goal, "history": history,
754
+ "eval_history": eval_history, "workdir": workdir,
755
+ }
756
+ return _finish('max')
757
+
758
+
759
+ # ── Interrupt Handler ─────────────────────────────────────────────────────────
760
+
761
+ def _handle_interrupt(goal: str, workdir: str, args, max_iter: int) -> str:
762
+ """
763
+ ESC 중단 후 목표 편집 프롬프트 표시.
764
+ 새 목표로 루프를 재실행하고, 최종 목표 문자열 반환.
765
+ """
766
+ console.print(
767
+ "\n[yellow]⚠ 중단됨.[/yellow] "
768
+ "목표를 수정하거나 Enter로 재실행, Ctrl+C로 REPL 복귀:"
769
+ )
770
+ try:
771
+ new_goal = pt_prompt(
772
+ HTML('<ansiyellow><b>목표 수정</b></ansiyellow> <ansiwhite>></ansiwhite> '),
773
+ default=goal,
774
+ style=PROMPT_STYLE,
775
+ ).strip()
776
+ except (EOFError, KeyboardInterrupt):
777
+ console.print("[dim]취소됨. REPL로 돌아갑니다.[/dim]")
778
+ return goal
779
+
780
+ if not new_goal:
781
+ new_goal = goal # Enter만 누르면 동일 목표 재실행
782
+
783
+ layout2 = make_layout()
784
+ layout2["header"].update(render_header(new_goal, 0, max_iter, "idle"))
785
+ layout2["worker"].update(render_worker_panel(deque(), 0, "idle", [], False))
786
+ layout2["evaluator"].update(render_evaluator_panel([], 0, "idle", False))
787
+ live2 = Live(layout2, refresh_per_second=8, screen=False)
788
+
789
+ # 목표가 바뀌면 히스토리 없이 fresh 실행
790
+ outcome = run_agent_loop(
791
+ new_goal, workdir, args.worker_model, args.eval_model,
792
+ max_iter, layout2, live2,
793
+ )
794
+ if outcome == 'interrupted':
795
+ return _handle_interrupt(new_goal, workdir, args, max_iter)
796
+ elif outcome == 'max':
797
+ console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
798
+ console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
799
+ return new_goal
576
800
 
577
801
 
578
802
  # ── Main ──────────────────────────────────────────────────────────────────────
@@ -582,6 +806,17 @@ def main():
582
806
  prog="agentforge",
583
807
  description="Worker + Evaluator 인터랙티브 멀티에이전트 CLI",
584
808
  )
809
+ sub = parser.add_subparsers(dest="subcommand")
810
+
811
+ auth_parser = sub.add_parser("auth", help="인증 관리 (ChatGPT Plus/Pro)")
812
+ auth_sub = auth_parser.add_subparsers(dest="auth_cmd")
813
+
814
+ login_parser = auth_sub.add_parser("login", help="로그인")
815
+ login_parser.add_argument("--device", action="store_true",
816
+ help="SSH/헤드리스 환경용 device auth 사용")
817
+ auth_sub.add_parser("logout", help="로그아웃")
818
+ auth_sub.add_parser("status", help="현재 인증 상태 확인")
819
+
585
820
  parser.add_argument("-d", "--dir", default=".", metavar="DIR",
586
821
  help="작업 디렉토리 (기본: 현재 디렉토리)")
587
822
  parser.add_argument("--worker-model", default=None, metavar="MODEL",
@@ -592,6 +827,21 @@ def main():
592
827
  metavar="N", help=f"최대 반복 횟수 (기본: {DEFAULT_MAX_ITER})")
593
828
  args = parser.parse_args()
594
829
 
830
+ # ── auth 서브커맨드 ────────────────────────────────────────────────
831
+ if args.subcommand == "auth":
832
+ if not CODEX_BIN.exists():
833
+ console.print(f"[red]Error: codex not found at {CODEX_BIN}[/red]")
834
+ sys.exit(1)
835
+ if args.auth_cmd == "login":
836
+ cmd_auth_login(device=getattr(args, "device", False))
837
+ elif args.auth_cmd == "logout":
838
+ cmd_auth_logout()
839
+ elif args.auth_cmd == "status":
840
+ cmd_auth_status()
841
+ else:
842
+ auth_parser.print_help()
843
+ return
844
+
595
845
  if not CODEX_BIN.exists():
596
846
  console.print(f"[bold red]Error:[/bold red] codex not found at {CODEX_BIN}")
597
847
  sys.exit(1)
@@ -611,6 +861,27 @@ def main():
611
861
  live.stop()
612
862
 
613
863
  # ── REPL 루프 ─────────────────────────────────────────────────────
864
+ status = auth_status()
865
+ if not status or not status["has_token"]:
866
+ console.print(
867
+ "[yellow]⚠ 로그인되지 않았습니다.[/yellow] "
868
+ "지금 로그인하시겠습니까? ([bold]y[/bold]/n/device) ",
869
+ end="",
870
+ )
871
+ try:
872
+ ans = input().strip().lower()
873
+ except (EOFError, KeyboardInterrupt):
874
+ ans = "n"
875
+ if ans in ("y", ""):
876
+ cmd_auth_login(device=False)
877
+ elif ans == "device":
878
+ cmd_auth_login(device=True)
879
+ else:
880
+ console.print(
881
+ "[dim]로그인을 건너뜁니다. "
882
+ "agentforge auth login 으로 나중에 로그인할 수 있습니다.[/dim]"
883
+ )
884
+
614
885
  console.print()
615
886
  console.print("[dim]명령을 입력하세요. /plan <목표> | /exit[/dim]")
616
887
 
@@ -664,13 +935,47 @@ def main():
664
935
  final_goal, workdir, args.worker_model, args.eval_model,
665
936
  max_iter, layout2, live2,
666
937
  )
667
- if outcome == 'max':
938
+ goal = final_goal
939
+ if outcome == 'interrupted':
940
+ goal = _handle_interrupt(goal, workdir, args, max_iter)
941
+ elif outcome == 'max':
668
942
  console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
669
- console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
943
+ if outcome != 'interrupted':
944
+ console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
945
+
946
+ elif cmd_name == 'resume':
947
+ if not _last_session:
948
+ console.print("[yellow]재개할 세션이 없습니다. 먼저 목표를 실행하세요.[/yellow]")
949
+ continue
950
+ s = _last_session
951
+ prev_iters = len(s["history"])
952
+ console.print(Rule("[cyan]세션 재개[/cyan]"))
953
+ console.print(f"[dim]목표:[/dim] {s['goal'][:80]}")
954
+ console.print(f"[dim]이전 반복:[/dim] {prev_iters}회 → 이어서 실행")
955
+ console.print()
956
+ layout2 = make_layout()
957
+ layout2["header"].update(render_header(s["goal"], prev_iters, max_iter, "idle"))
958
+ layout2["worker"].update(render_worker_panel(deque(), prev_iters, "idle", s["history"], False))
959
+ layout2["evaluator"].update(render_evaluator_panel(s["eval_history"], prev_iters, "idle", False))
960
+ live2 = Live(layout2, refresh_per_second=8, screen=False)
961
+ outcome = run_agent_loop(
962
+ s["goal"], s["workdir"],
963
+ args.worker_model, args.eval_model,
964
+ max_iter, layout2, live2,
965
+ initial_history=s["history"],
966
+ initial_eval_history=s["eval_history"],
967
+ )
968
+ goal = s["goal"]
969
+ if outcome == 'interrupted':
970
+ goal = _handle_interrupt(goal, workdir, args, max_iter)
971
+ elif outcome == 'max':
972
+ console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
973
+ if outcome != 'interrupted':
974
+ console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
670
975
 
671
976
  else:
672
977
  console.print(f"[red]알 수 없는 커맨드: /{cmd_name}[/red]")
673
- console.print("[dim]사용 가능: /plan <목표> /exit[/dim]")
978
+ console.print("[dim]사용 가능: /plan <목표> /resume /exit[/dim]")
674
979
 
675
980
  else:
676
981
  # 일반 텍스트 → 바로 Worker에게 목표로 전달
@@ -684,9 +989,12 @@ def main():
684
989
  goal, workdir, args.worker_model, args.eval_model,
685
990
  max_iter, layout2, live2,
686
991
  )
687
- if outcome == 'max':
992
+ if outcome == 'interrupted':
993
+ goal = _handle_interrupt(goal, workdir, args, max_iter)
994
+ elif outcome == 'max':
688
995
  console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
689
- console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
996
+ if outcome != 'interrupted':
997
+ console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
690
998
 
691
999
 
692
1000
  if __name__ == "__main__":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentforge-multi",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Multi-agent CLI: Worker + Evaluator agents collaborate in a loop to achieve your goal",
5
5
  "keywords": [
6
6
  "ai",