agentforge-multi 0.1.0 → 0.1.2

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,14 @@ def parse_decision(text: str) -> tuple[str, str]:
316
420
  # ── TUI ───────────────────────────────────────────────────────────────────────
317
421
 
318
422
  def make_layout() -> Layout:
423
+ import shutil
424
+ term_h = shutil.get_terminal_size(fallback=(80, 40)).lines
425
+ body_size = max(10, term_h - 8) # header(3) + 여백(5)
426
+
319
427
  layout = Layout()
320
428
  layout.split(
321
429
  Layout(name="header", size=3),
322
- Layout(name="body"),
430
+ Layout(name="body", size=body_size),
323
431
  )
324
432
  layout["body"].split_row(
325
433
  Layout(name="worker"),
@@ -332,37 +440,57 @@ def render_header(goal: str, iteration: int, max_iter: int, phase: str) -> Panel
332
440
  g = goal[:72] + "..." if len(goal) > 72 else goal
333
441
  status = {
334
442
  "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]",
443
+ "worker": "[bold yellow on black] WORKER 실행 [/bold yellow on black] [dim]Evaluator 대기[/dim]",
444
+ "evaluator": "[dim]Worker 완료[/dim] → [bold magenta on black] EVALUATOR 평가 [/bold magenta on black]",
445
+ "planning": "[bold cyan on black] PLAN AGENT 실행 [/bold cyan on black]",
446
+ "done": "[bold green]✓ 완료[/bold green]",
447
+ "max": "[bold red]최대 반복 도달[/bold red]",
340
448
  }.get(phase, phase)
341
449
  iter_str = f" Iter [bold]{iteration}[/bold]/{max_iter}" if iteration > 0 else ""
342
450
  content = f"[bold cyan]Goal:[/bold cyan] {g}\n{status}{iter_str}"
343
451
  return Panel(content, border_style="cyan", box=box.HEAVY_HEAD)
344
452
 
345
453
 
454
+ _DECISION_BADGE = {
455
+ "DONE": "[green]✓[/green]",
456
+ "IMPROVE": "[yellow]~[/yellow]",
457
+ "REDIRECT": "[red]↩[/red]",
458
+ }
459
+
346
460
  def render_worker_panel(buf: deque, iteration: int, status: str,
347
461
  history: list, done: bool) -> Panel:
348
462
  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]")
463
+ # 오래된 기록 (직전 제외): summary + 배지
464
+ for h in history[:-1]:
465
+ badge = _DECISION_BADGE.get(h['decision'], '[dim]?[/dim]')
466
+ summary = (h['worker_summary'] or '')[:70]
467
+ lines.append(f"[dim]Iter {h['iter']}[/dim] {badge} [dim]{summary}[/dim]")
468
+ # 직전 기록: worker_lines 최대 8줄 (밝게)
469
+ if history:
470
+ h = history[-1]
471
+ lines.append(f"[bold]── Iter {h['iter']} ──────────────────────────────[/bold]")
472
+ for l in h.get('worker_lines', [])[-8:]:
473
+ lines.append(f"[white]{l}[/white]")
474
+ # 현재 스트리밍
353
475
  if buf or status == "running":
354
476
  if history:
355
- lines.append(f"[yellow]── Iter {iteration} {'─'*30}[/yellow]")
477
+ lines.append(f"[yellow bold]── Iter {iteration} (실행 중) ──────────────[/yellow bold]")
356
478
  for line in list(buf):
357
479
  lines.append(line)
358
480
  if status == "running":
359
481
  lines.append("[yellow blink]▌[/yellow blink]")
360
482
  border = "green" if done else "yellow"
361
- icon = "✓" if done else ""
483
+ is_active = (status == "running")
484
+ if done:
485
+ title = "[green bold]✓ WORKER AGENT[/green bold]"
486
+ elif is_active:
487
+ title = "[yellow bold]⚙ WORKER AGENT ◀ 실행 중[/yellow bold]"
488
+ else:
489
+ title = "[dim]⚙ WORKER AGENT[/dim]"
362
490
  content = "\n".join(lines) if lines else "[dim]명령을 입력하면 작업을 시작합니다.[/dim]"
363
491
  return Panel(
364
492
  Text.from_markup(content),
365
- title=f"[{border}]{icon} WORKER AGENT[/{border}]",
493
+ title=title,
366
494
  border_style=border, box=box.ROUNDED, padding=(0, 1),
367
495
  )
368
496
 
@@ -370,37 +498,50 @@ def render_worker_panel(buf: deque, iteration: int, status: str,
370
498
  def render_evaluator_panel(eval_history: list, iteration: int,
371
499
  phase: str, done: bool) -> Panel:
372
500
  lines = []
373
- for e in eval_history:
501
+ # 오래된 기록 (최신 제외): 배지 + 요약 한 줄
502
+ for e in eval_history[:-1]:
503
+ d, fb = e['decision'], e['feedback']
504
+ badge = {"DONE": "[green]✓ DONE[/green]",
505
+ "IMPROVE": "[yellow]IMPROVE[/yellow]",
506
+ "REDIRECT": "[red]REDIRECT[/red]"}.get(d, d)
507
+ short_fb = fb[:65] + ('…' if len(fb) > 65 else '')
508
+ lines.append(f"[dim]Iter {e['iter']}[/dim] {badge} [dim]{short_fb}[/dim]")
509
+ # 최신 기록: 전체 피드백 표시
510
+ if eval_history:
511
+ e = eval_history[-1]
374
512
  d, fb, fm = e['decision'], e['feedback'], e.get('full_msg', '')
375
- lines.append(f"[dim]── Iter {e['iter']} {'─'*28}[/dim]")
513
+ lines.append(f"[bold]── Iter {e['iter']} ──────────────────────────────[/bold]")
376
514
  if d == 'DONE':
377
515
  lines.append("[bold green]✓ DONE[/bold green]")
378
- # 한국어 요약 섹션 추출
379
516
  for section in ['판단 이유', '결과물 위치', '결과 요약']:
380
517
  m = re.search(rf'{section}:\s*(.+?)(?=\n판단|결과물|결과 요약|$)', fm, re.S)
381
518
  if m:
382
- val = m.group(1).strip()[:120]
383
- lines.append(f"[dim]{section}:[/dim] [white]{val}[/white]")
519
+ val = m.group(1).strip()[:200]
520
+ lines.append(f"[cyan]{section}:[/cyan] [white]{val}[/white]")
384
521
  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]")
522
+ lines.append("[bold yellow]IMPROVE[/bold yellow]")
523
+ lines.append(f"[yellow]{fb}[/yellow]")
388
524
  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]")
525
+ lines.append("[bold red]REDIRECT[/bold red]")
526
+ lines.append(f"[red]{fb}[/red]")
392
527
  lines.append("")
393
528
  if phase == "evaluator":
394
- lines.append(f"[magenta]── Iter {iteration} {'─'*28}[/magenta]")
529
+ lines.append(f"[magenta bold]── Iter {iteration} (평가 중) ──────────────[/magenta bold]")
395
530
  lines.append("[magenta blink]평가 중...[/magenta blink]")
396
531
  elif phase == "planning":
397
532
  lines.append("[cyan blink]계획 수립 중...[/cyan blink]")
398
533
  border = "green" if done else "magenta"
399
- icon = "✓" if done else ""
534
+ is_active = (phase == "evaluator")
535
+ if done:
536
+ title = "[green bold]✓ EVALUATOR AGENT[/green bold]"
537
+ elif is_active:
538
+ title = "[magenta bold]◈ EVALUATOR AGENT ◀ 평가 중[/magenta bold]"
539
+ else:
540
+ title = "[dim]◈ EVALUATOR AGENT[/dim]"
400
541
  content = "\n".join(lines) if lines else "[dim]Worker 완료 후 평가를 시작합니다.[/dim]"
401
542
  return Panel(
402
543
  Text.from_markup(content),
403
- title=f"[{border}]{icon} EVALUATOR AGENT[/{border}]",
544
+ title=title,
404
545
  border_style=border, box=box.ROUNDED, padding=(0, 1),
405
546
  )
406
547
 
@@ -485,17 +626,30 @@ def run_plan_mode(raw_goal: str, workdir: str, model: str | None,
485
626
 
486
627
  def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
487
628
  eval_model: str | None, max_iter: int,
488
- layout: Layout, live: Live) -> str:
629
+ layout: Layout, live: Live,
630
+ initial_history: list | None = None,
631
+ initial_eval_history: list | None = None) -> str:
489
632
  """
490
633
  Worker + Evaluator 반복 루프.
491
634
  반환: 'done' | 'max'
492
635
  """
493
- history = []
494
- eval_history = []
636
+ global _last_session
637
+ history = list(initial_history or [])
638
+ eval_history = list(initial_eval_history or [])
495
639
  worker_buf = deque(maxlen=WORKER_BUF_LINES)
496
640
  worker_status = ["idle"]
497
641
  done = False
498
642
 
643
+ # ESC 리스너 시작
644
+ _interrupt_event.clear()
645
+ _esc_stop = threading.Event()
646
+ esc_t = threading.Thread(target=_esc_listener, args=(_esc_stop,), daemon=True)
647
+ esc_t.start()
648
+
649
+ def _finish(result: str) -> str:
650
+ _esc_stop.set()
651
+ return result
652
+
499
653
  def refresh(phase: str, iteration: int):
500
654
  layout["header"].update(render_header(goal, iteration, max_iter, phase))
501
655
  layout["worker"].update(render_worker_panel(
@@ -521,11 +675,22 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
521
675
  t = threading.Thread(target=_worker, daemon=True)
522
676
  t.start()
523
677
  while t.is_alive():
678
+ if _interrupt_event.is_set():
679
+ if _current_proc:
680
+ _current_proc.kill()
681
+ break
524
682
  refresh("worker", iteration)
525
683
  time.sleep(0.1)
526
684
  t.join()
527
685
  refresh("worker", iteration)
528
686
 
687
+ # ESC로 중단된 경우
688
+ if _interrupt_event.is_set():
689
+ live.stop()
690
+ _last_session = {"goal": goal, "history": history,
691
+ "eval_history": eval_history, "workdir": workdir}
692
+ return _finish('interrupted')
693
+
529
694
  last_msg, _ = worker_result
530
695
  worker_summary = (last_msg or "").replace('\n', ' ')[:300]
531
696
  history.append({
@@ -537,6 +702,12 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
537
702
  })
538
703
 
539
704
  # ── Evaluator ────────────────────────────────────────────────────
705
+ if _interrupt_event.is_set():
706
+ live.stop()
707
+ _last_session = {"goal": goal, "history": history,
708
+ "eval_history": eval_history, "workdir": workdir}
709
+ return _finish('interrupted')
710
+
540
711
  refresh("evaluator", iteration)
541
712
  eval_prompt = build_evaluator_prompt(goal, last_msg or "", iteration)
542
713
  eval_msg, _ = run_evaluator(eval_prompt, workdir, eval_model)
@@ -556,6 +727,10 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
556
727
  refresh("done", iteration)
557
728
  time.sleep(0.8)
558
729
  live.stop()
730
+ _last_session = {
731
+ "goal": goal, "history": history,
732
+ "eval_history": eval_history, "workdir": workdir,
733
+ }
559
734
 
560
735
  # 완료 요약 출력
561
736
  console.print()
@@ -569,10 +744,57 @@ def run_agent_loop(goal: str, workdir: str, worker_model: str | None,
569
744
  for line in val.splitlines():
570
745
  console.print(f" [{color}]{line}[/{color}]")
571
746
  console.print()
572
- return 'done'
747
+ return _finish('done')
573
748
 
574
749
  live.stop()
575
- return 'max'
750
+ _last_session = {
751
+ "goal": goal, "history": history,
752
+ "eval_history": eval_history, "workdir": workdir,
753
+ }
754
+ return _finish('max')
755
+
756
+
757
+ # ── Interrupt Handler ─────────────────────────────────────────────────────────
758
+
759
+ def _handle_interrupt(goal: str, workdir: str, args, max_iter: int) -> str:
760
+ """
761
+ ESC 중단 후 목표 편집 프롬프트 표시.
762
+ 새 목표로 루프를 재실행하고, 최종 목표 문자열 반환.
763
+ """
764
+ console.print(
765
+ "\n[yellow]⚠ 중단됨.[/yellow] "
766
+ "목표를 수정하거나 Enter로 재실행, Ctrl+C로 REPL 복귀:"
767
+ )
768
+ try:
769
+ new_goal = pt_prompt(
770
+ HTML('<ansiyellow><b>목표 수정</b></ansiyellow> <ansiwhite>></ansiwhite> '),
771
+ default=goal,
772
+ style=PROMPT_STYLE,
773
+ ).strip()
774
+ except (EOFError, KeyboardInterrupt):
775
+ console.print("[dim]취소됨. REPL로 돌아갑니다.[/dim]")
776
+ return goal
777
+
778
+ if not new_goal:
779
+ new_goal = goal # Enter만 누르면 동일 목표 재실행
780
+
781
+ layout2 = make_layout()
782
+ layout2["header"].update(render_header(new_goal, 0, max_iter, "idle"))
783
+ layout2["worker"].update(render_worker_panel(deque(), 0, "idle", [], False))
784
+ layout2["evaluator"].update(render_evaluator_panel([], 0, "idle", False))
785
+ live2 = Live(layout2, refresh_per_second=8, screen=False)
786
+
787
+ # 목표가 바뀌면 히스토리 없이 fresh 실행
788
+ outcome = run_agent_loop(
789
+ new_goal, workdir, args.worker_model, args.eval_model,
790
+ max_iter, layout2, live2,
791
+ )
792
+ if outcome == 'interrupted':
793
+ return _handle_interrupt(new_goal, workdir, args, max_iter)
794
+ elif outcome == 'max':
795
+ console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
796
+ console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
797
+ return new_goal
576
798
 
577
799
 
578
800
  # ── Main ──────────────────────────────────────────────────────────────────────
@@ -582,6 +804,17 @@ def main():
582
804
  prog="agentforge",
583
805
  description="Worker + Evaluator 인터랙티브 멀티에이전트 CLI",
584
806
  )
807
+ sub = parser.add_subparsers(dest="subcommand")
808
+
809
+ auth_parser = sub.add_parser("auth", help="인증 관리 (ChatGPT Plus/Pro)")
810
+ auth_sub = auth_parser.add_subparsers(dest="auth_cmd")
811
+
812
+ login_parser = auth_sub.add_parser("login", help="로그인")
813
+ login_parser.add_argument("--device", action="store_true",
814
+ help="SSH/헤드리스 환경용 device auth 사용")
815
+ auth_sub.add_parser("logout", help="로그아웃")
816
+ auth_sub.add_parser("status", help="현재 인증 상태 확인")
817
+
585
818
  parser.add_argument("-d", "--dir", default=".", metavar="DIR",
586
819
  help="작업 디렉토리 (기본: 현재 디렉토리)")
587
820
  parser.add_argument("--worker-model", default=None, metavar="MODEL",
@@ -592,6 +825,21 @@ def main():
592
825
  metavar="N", help=f"최대 반복 횟수 (기본: {DEFAULT_MAX_ITER})")
593
826
  args = parser.parse_args()
594
827
 
828
+ # ── auth 서브커맨드 ────────────────────────────────────────────────
829
+ if args.subcommand == "auth":
830
+ if not CODEX_BIN.exists():
831
+ console.print(f"[red]Error: codex not found at {CODEX_BIN}[/red]")
832
+ sys.exit(1)
833
+ if args.auth_cmd == "login":
834
+ cmd_auth_login(device=getattr(args, "device", False))
835
+ elif args.auth_cmd == "logout":
836
+ cmd_auth_logout()
837
+ elif args.auth_cmd == "status":
838
+ cmd_auth_status()
839
+ else:
840
+ auth_parser.print_help()
841
+ return
842
+
595
843
  if not CODEX_BIN.exists():
596
844
  console.print(f"[bold red]Error:[/bold red] codex not found at {CODEX_BIN}")
597
845
  sys.exit(1)
@@ -604,14 +852,31 @@ def main():
604
852
  layout["header"].update(render_header("", 0, max_iter, "idle"))
605
853
  layout["worker"].update(render_worker_panel(deque(), 0, "idle", [], False))
606
854
  layout["evaluator"].update(render_evaluator_panel([], 0, "idle", False))
607
-
608
- live = Live(layout, refresh_per_second=8, screen=False)
609
- live.start()
610
- time.sleep(0.3)
611
- live.stop()
855
+ with Live(layout, refresh_per_second=8, screen=False) as live:
856
+ time.sleep(0.3)
612
857
 
613
858
  # ── REPL 루프 ─────────────────────────────────────────────────────
614
- console.print()
859
+ status = auth_status()
860
+ if not status or not status["has_token"]:
861
+ console.print(
862
+ "[yellow]⚠ 로그인되지 않았습니다.[/yellow] "
863
+ "지금 로그인하시겠습니까? ([bold]y[/bold]/n/device) ",
864
+ end="",
865
+ )
866
+ try:
867
+ ans = input().strip().lower()
868
+ except (EOFError, KeyboardInterrupt):
869
+ ans = "n"
870
+ if ans in ("y", ""):
871
+ cmd_auth_login(device=False)
872
+ elif ans == "device":
873
+ cmd_auth_login(device=True)
874
+ else:
875
+ console.print(
876
+ "[dim]로그인을 건너뜁니다. "
877
+ "agentforge auth login 으로 나중에 로그인할 수 있습니다.[/dim]"
878
+ )
879
+
615
880
  console.print("[dim]명령을 입력하세요. /plan <목표> | /exit[/dim]")
616
881
 
617
882
  _completer = SlashCompleter()
@@ -664,13 +929,47 @@ def main():
664
929
  final_goal, workdir, args.worker_model, args.eval_model,
665
930
  max_iter, layout2, live2,
666
931
  )
667
- if outcome == 'max':
932
+ goal = final_goal
933
+ if outcome == 'interrupted':
934
+ goal = _handle_interrupt(goal, workdir, args, max_iter)
935
+ elif outcome == 'max':
668
936
  console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
669
- console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
937
+ if outcome != 'interrupted':
938
+ console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
939
+
940
+ elif cmd_name == 'resume':
941
+ if not _last_session:
942
+ console.print("[yellow]재개할 세션이 없습니다. 먼저 목표를 실행하세요.[/yellow]")
943
+ continue
944
+ s = _last_session
945
+ prev_iters = len(s["history"])
946
+ console.print(Rule("[cyan]세션 재개[/cyan]"))
947
+ console.print(f"[dim]목표:[/dim] {s['goal'][:80]}")
948
+ console.print(f"[dim]이전 반복:[/dim] {prev_iters}회 → 이어서 실행")
949
+ console.print()
950
+ layout2 = make_layout()
951
+ layout2["header"].update(render_header(s["goal"], prev_iters, max_iter, "idle"))
952
+ layout2["worker"].update(render_worker_panel(deque(), prev_iters, "idle", s["history"], False))
953
+ layout2["evaluator"].update(render_evaluator_panel(s["eval_history"], prev_iters, "idle", False))
954
+ live2 = Live(layout2, refresh_per_second=8, screen=False)
955
+ outcome = run_agent_loop(
956
+ s["goal"], s["workdir"],
957
+ args.worker_model, args.eval_model,
958
+ max_iter, layout2, live2,
959
+ initial_history=s["history"],
960
+ initial_eval_history=s["eval_history"],
961
+ )
962
+ goal = s["goal"]
963
+ if outcome == 'interrupted':
964
+ goal = _handle_interrupt(goal, workdir, args, max_iter)
965
+ elif outcome == 'max':
966
+ console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
967
+ if outcome != 'interrupted':
968
+ console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
670
969
 
671
970
  else:
672
971
  console.print(f"[red]알 수 없는 커맨드: /{cmd_name}[/red]")
673
- console.print("[dim]사용 가능: /plan <목표> /exit[/dim]")
972
+ console.print("[dim]사용 가능: /plan <목표> /resume /exit[/dim]")
674
973
 
675
974
  else:
676
975
  # 일반 텍스트 → 바로 Worker에게 목표로 전달
@@ -684,9 +983,12 @@ def main():
684
983
  goal, workdir, args.worker_model, args.eval_model,
685
984
  max_iter, layout2, live2,
686
985
  )
687
- if outcome == 'max':
986
+ if outcome == 'interrupted':
987
+ goal = _handle_interrupt(goal, workdir, args, max_iter)
988
+ elif outcome == 'max':
688
989
  console.print(f"[red]{max_iter}번 반복 후에도 완료되지 않았습니다.[/red]")
689
- console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
990
+ if outcome != 'interrupted':
991
+ console.print("[dim]다음 명령을 입력하세요. /exit 로 종료.[/dim]")
690
992
 
691
993
 
692
994
  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.2",
4
4
  "description": "Multi-agent CLI: Worker + Evaluator agents collaborate in a loop to achieve your goal",
5
5
  "keywords": [
6
6
  "ai",