claude-controller 0.2.0 → 0.3.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.
Files changed (68) hide show
  1. package/README.md +2 -2
  2. package/bin/autoloop.sh +382 -0
  3. package/bin/ctl +327 -5
  4. package/bin/native-app.py +5 -2
  5. package/bin/watchdog.sh +357 -0
  6. package/cognitive/__init__.py +14 -0
  7. package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
  8. package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
  9. package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
  10. package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
  11. package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
  12. package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
  13. package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
  14. package/cognitive/dispatcher.py +192 -0
  15. package/cognitive/evaluator.py +289 -0
  16. package/cognitive/goal_engine.py +232 -0
  17. package/cognitive/learning.py +189 -0
  18. package/cognitive/orchestrator.py +303 -0
  19. package/cognitive/planner.py +207 -0
  20. package/cognitive/prompts/analyst.md +31 -0
  21. package/cognitive/prompts/coder.md +22 -0
  22. package/cognitive/prompts/reviewer.md +33 -0
  23. package/cognitive/prompts/tester.md +21 -0
  24. package/cognitive/prompts/writer.md +25 -0
  25. package/config.sh +6 -1
  26. package/dag/__init__.py +5 -0
  27. package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/dag/__pycache__/graph.cpython-314.pyc +0 -0
  29. package/dag/graph.py +222 -0
  30. package/lib/jobs.sh +12 -1
  31. package/package.json +5 -1
  32. package/postinstall.sh +1 -1
  33. package/service/controller.sh +43 -11
  34. package/web/audit.py +122 -0
  35. package/web/checkpoint.py +80 -0
  36. package/web/config.py +2 -5
  37. package/web/handler.py +464 -26
  38. package/web/handler_fs.py +15 -14
  39. package/web/handler_goals.py +203 -0
  40. package/web/handler_jobs.py +165 -42
  41. package/web/handler_memory.py +203 -0
  42. package/web/jobs.py +576 -12
  43. package/web/personas.py +419 -0
  44. package/web/pipeline.py +682 -50
  45. package/web/presets.py +506 -0
  46. package/web/projects.py +58 -4
  47. package/web/static/api.js +90 -3
  48. package/web/static/app.js +8 -0
  49. package/web/static/base.css +51 -12
  50. package/web/static/context.js +14 -4
  51. package/web/static/form.css +3 -2
  52. package/web/static/goals.css +363 -0
  53. package/web/static/goals.js +300 -0
  54. package/web/static/i18n.js +288 -0
  55. package/web/static/index.html +142 -6
  56. package/web/static/jobs.css +951 -4
  57. package/web/static/jobs.js +890 -54
  58. package/web/static/memoryview.js +117 -0
  59. package/web/static/personas.js +228 -0
  60. package/web/static/pipeline.css +308 -1
  61. package/web/static/pipelines.js +249 -14
  62. package/web/static/presets.js +244 -0
  63. package/web/static/send.js +26 -4
  64. package/web/static/settings-style.css +34 -3
  65. package/web/static/settings.js +37 -1
  66. package/web/static/stream.js +242 -19
  67. package/web/static/utils.js +54 -2
  68. package/web/webhook.py +210 -0
package/bin/ctl CHANGED
@@ -36,6 +36,11 @@ Usage:
36
36
  ctl pipeline tick 모든 활성 파이프라인 진행
37
37
  ctl pipeline reset --id <id> 상태 초기화
38
38
  ctl pipeline delete --id <id> 파이프라인 삭제
39
+ ctl summary 전체 상태 요약 (AI 컨텍스트용)
40
+ ctl stats [--limit N] 작업 통계 집계
41
+ ctl health 헬스체크 + 디스크 + stuck 작업
42
+ ctl watchdog [start|stop|status|install|uninstall] 프로세스 워치독 관리
43
+ ctl job <id> text 최종 텍스트 결과만 추출
39
44
 
40
45
  Output: 기본 JSON. --pretty 플래그로 포맷팅된 출력.
41
46
  """
@@ -55,7 +60,7 @@ sys.path.insert(0, str(CONTROLLER_DIR / "web"))
55
60
  from config import (
56
61
  LOGS_DIR, UPLOADS_DIR, DATA_DIR,
57
62
  RECENT_DIRS_FILE, SETTINGS_FILE, SESSIONS_DIR,
58
- CLAUDE_PROJECTS_DIR,
63
+ CLAUDE_PROJECTS_DIR, FIFO_PATH,
59
64
  )
60
65
  from jobs import get_all_jobs, get_job_result, send_to_fifo, start_controller_service, stop_controller_service
61
66
  from checkpoint import get_job_checkpoints, rewind_job
@@ -65,6 +70,7 @@ from pipeline import (
65
70
  list_pipelines, get_pipeline_status, create_pipeline, delete_pipeline,
66
71
  run_next, force_run, reset_phase, tick, tick_all,
67
72
  )
73
+ import shutil
68
74
 
69
75
 
70
76
  # ══════════════════════════════════════════════════════════════
@@ -245,6 +251,11 @@ def cmd_job(args):
245
251
  err(error)
246
252
  out(result, args.pretty)
247
253
 
254
+ # ── text (최종 텍스트만) ──
255
+ elif args.action == "text":
256
+ cmd_job_text(job_id, args.pretty, raw=getattr(args, "raw", False))
257
+ return
258
+
248
259
  # ── 기본: 결과 조회 ──
249
260
  else:
250
261
  result, error = get_job_result(job_id)
@@ -349,7 +360,7 @@ def cmd_sessions(args):
349
360
  def cmd_config(args):
350
361
  """설정 조회 또는 변경."""
351
362
  defaults = {
352
- "skip_permissions": True,
363
+ "skip_permissions": False,
353
364
  "allowed_tools": "Bash,Read,Write,Edit,Glob,Grep,Agent,NotebookEdit,WebFetch,WebSearch",
354
365
  "model": "",
355
366
  "max_jobs": 10,
@@ -734,6 +745,296 @@ def cmd_pipeline(args):
734
745
  err(error)
735
746
  out({"deleted": True, "pipeline": result}, args.pretty)
736
747
 
748
+ elif action == "evolution":
749
+ from pipeline import get_evolution_summary
750
+ summary = get_evolution_summary()
751
+ if args.pretty:
752
+ print(f"파이프라인 진화 요약")
753
+ print(f" 활성: {summary['active_count']}/{summary['total_pipelines']}")
754
+ print(f" 총 실행: {summary['total_runs']}회 | 총 비용: ${summary['total_cost_usd']}")
755
+ print(f" 효율성: {summary['efficiency_pct']}% (변경 있는 실행 비율)")
756
+ cls = summary["classifications"]
757
+ print(f" 분류: 변경있음={cls.get('has_change',0)} / 변경없음={cls.get('no_change',0)} / 불명={cls.get('unknown',0)}")
758
+ if summary["interval_adaptations"]:
759
+ print(f" 인터벌 적응:")
760
+ for ia in summary["interval_adaptations"]:
761
+ print(f" {ia['name']}: {ia['base_sec']}s → {ia['effective_sec']}s ({ia['change_pct']:+d}%)")
762
+ else:
763
+ out(summary, False)
764
+
765
+
766
+ # ══════════════════════════════════════════════════════════════
767
+ # AI 컨텍스트 명령어 — summary, stats, health, job text
768
+ # ══════════════════════════════════════════════════════════════
769
+
770
+ def cmd_summary(args):
771
+ """서비스 + 최근 작업 + 파이프라인을 한 번에 요약."""
772
+ running, pid = is_service_running()
773
+
774
+ jobs = get_all_jobs()
775
+ total = len(jobs)
776
+ by_status = {}
777
+ recent_failures = []
778
+ total_cost = 0.0
779
+ running_jobs = []
780
+
781
+ for j in jobs:
782
+ st = j.get("status", "unknown")
783
+ by_status[st] = by_status.get(st, 0) + 1
784
+ if j.get("cost_usd"):
785
+ total_cost += j["cost_usd"]
786
+ if st == "running":
787
+ running_jobs.append({
788
+ "job_id": j["job_id"],
789
+ "prompt": (j.get("prompt") or "")[:80],
790
+ "cwd": j.get("cwd"),
791
+ })
792
+ if st == "failed":
793
+ recent_failures.append({
794
+ "job_id": j["job_id"],
795
+ "prompt": (j.get("prompt") or "")[:80],
796
+ "result": (j.get("result") or "")[:200],
797
+ })
798
+
799
+ done = by_status.get("done", 0)
800
+ failed = by_status.get("failed", 0)
801
+ success_rate = round(done / (done + failed), 3) if (done + failed) > 0 else None
802
+
803
+ # 파이프라인 요약
804
+ pipes = list_pipelines()
805
+ active_pipes = [
806
+ {"id": p["id"], "name": p.get("name", ""), "status": p.get("status", ""), "command": (p.get("command") or "")[:60]}
807
+ for p in pipes if p.get("status") in ("running", "idle")
808
+ ]
809
+
810
+ result = {
811
+ "service": {"running": running, "pid": pid},
812
+ "jobs": {
813
+ "total": total,
814
+ "by_status": by_status,
815
+ "success_rate": success_rate,
816
+ "total_cost_usd": round(total_cost, 4),
817
+ "running": running_jobs,
818
+ "recent_failures": recent_failures[:5],
819
+ },
820
+ "pipelines": {"total": len(pipes), "active": active_pipes},
821
+ }
822
+ out(result, args.pretty)
823
+
824
+
825
+ def cmd_stats(args):
826
+ """작업 통계 집계 — 성공률, 비용, 소요시간, 에러 패턴."""
827
+ jobs = get_all_jobs()
828
+
829
+ # 기간 필터
830
+ limit = args.limit or len(jobs)
831
+ jobs = jobs[:limit]
832
+
833
+ total = len(jobs)
834
+ done_count = 0
835
+ failed_count = 0
836
+ costs = []
837
+ durations = []
838
+ error_patterns = {}
839
+
840
+ for j in jobs:
841
+ st = j.get("status", "unknown")
842
+ if st == "done":
843
+ done_count += 1
844
+ elif st == "failed":
845
+ failed_count += 1
846
+ # 에러 패턴 수집
847
+ result_text = (j.get("result") or "")[:300]
848
+ if result_text:
849
+ # 첫 줄을 패턴 키로 사용
850
+ key = result_text.split("\n")[0][:80]
851
+ error_patterns[key] = error_patterns.get(key, 0) + 1
852
+
853
+ if j.get("cost_usd"):
854
+ costs.append(j["cost_usd"])
855
+ if j.get("duration_ms"):
856
+ durations.append(j["duration_ms"])
857
+
858
+ completed = done_count + failed_count
859
+ result = {
860
+ "total_jobs": total,
861
+ "completed": completed,
862
+ "done": done_count,
863
+ "failed": failed_count,
864
+ "success_rate": round(done_count / completed, 3) if completed > 0 else None,
865
+ "cost": {
866
+ "total_usd": round(sum(costs), 4) if costs else 0,
867
+ "avg_usd": round(sum(costs) / len(costs), 4) if costs else None,
868
+ "max_usd": round(max(costs), 4) if costs else None,
869
+ },
870
+ "duration": {
871
+ "avg_ms": round(sum(durations) / len(durations)) if durations else None,
872
+ "median_ms": round(sorted(durations)[len(durations) // 2]) if durations else None,
873
+ "max_ms": max(durations) if durations else None,
874
+ },
875
+ "top_errors": dict(sorted(error_patterns.items(), key=lambda x: -x[1])[:5]),
876
+ }
877
+ out(result, args.pretty)
878
+
879
+
880
+ def cmd_health(args):
881
+ """서비스 상태 + 디스크 사용량 + stuck 작업 + 고아 프로세스."""
882
+ running, pid = is_service_running()
883
+
884
+ # 디스크 사용량
885
+ def dir_size_mb(path):
886
+ total = 0
887
+ p = Path(path)
888
+ if not p.exists():
889
+ return 0
890
+ for f in p.rglob("*"):
891
+ if f.is_file():
892
+ total += f.stat().st_size
893
+ return round(total / (1024 * 1024), 2)
894
+
895
+ disk = {
896
+ "logs_mb": dir_size_mb(LOGS_DIR),
897
+ "uploads_mb": dir_size_mb(UPLOADS_DIR),
898
+ "worktrees_mb": dir_size_mb(CONTROLLER_DIR / "worktrees"),
899
+ }
900
+ total_disk = CONTROLLER_DIR.resolve()
901
+ usage = shutil.disk_usage(str(total_disk))
902
+ disk["system_free_gb"] = round(usage.free / (1024 ** 3), 2)
903
+
904
+ # Stuck 작업 (30분 이상 running)
905
+ jobs = get_all_jobs()
906
+ stuck = []
907
+ import datetime
908
+ now = datetime.datetime.now()
909
+ for j in jobs:
910
+ if j.get("status") != "running":
911
+ continue
912
+ created = j.get("created_at", "")
913
+ if not created:
914
+ continue
915
+ try:
916
+ started = datetime.datetime.strptime(created, "%Y-%m-%d %H:%M:%S")
917
+ elapsed_min = (now - started).total_seconds() / 60
918
+ if elapsed_min > 30:
919
+ stuck.append({
920
+ "job_id": j["job_id"],
921
+ "prompt": (j.get("prompt") or "")[:60],
922
+ "elapsed_min": round(elapsed_min, 1),
923
+ })
924
+ except ValueError:
925
+ continue
926
+
927
+ # FIFO 상태
928
+ fifo_ok = FIFO_PATH.exists() and Path(FIFO_PATH).is_fifo() if hasattr(Path, 'is_fifo') else FIFO_PATH.exists()
929
+
930
+ result = {
931
+ "service": {"running": running, "pid": pid, "fifo_ok": fifo_ok},
932
+ "disk": disk,
933
+ "stuck_jobs": stuck,
934
+ "job_counts": {
935
+ "total": len(jobs),
936
+ "running": sum(1 for j in jobs if j.get("status") == "running"),
937
+ },
938
+ }
939
+ out(result, args.pretty)
940
+
941
+
942
+ def cmd_watchdog(args):
943
+ """워치독 관리 — start/stop/status/install/uninstall."""
944
+ action = args.action or "status"
945
+ watchdog_sh = CONTROLLER_DIR / "bin" / "watchdog.sh"
946
+ state_file = CONTROLLER_DIR / "data" / "watchdog_state.json"
947
+ pid_file = CONTROLLER_DIR / "service" / "watchdog.pid"
948
+
949
+ if action in ("start", "stop", "install", "uninstall"):
950
+ import subprocess
951
+ result = subprocess.run(
952
+ ["bash", str(watchdog_sh), action],
953
+ capture_output=True, text=True
954
+ )
955
+ if result.stdout.strip():
956
+ if args.pretty:
957
+ out({"action": action, "message": result.stdout.strip()}, True)
958
+ else:
959
+ print(result.stdout.strip())
960
+ if result.returncode != 0 and result.stderr.strip():
961
+ print(result.stderr.strip(), file=sys.stderr)
962
+ sys.exit(result.returncode)
963
+ else:
964
+ # status — JSON 출력
965
+ running = False
966
+ pid = None
967
+ state = {}
968
+
969
+ if pid_file.exists():
970
+ try:
971
+ pid = int(pid_file.read_text().strip())
972
+ import signal
973
+ os.kill(pid, 0)
974
+ running = True
975
+ except (ValueError, OSError):
976
+ pid = None
977
+
978
+ if state_file.exists():
979
+ try:
980
+ state = json.loads(state_file.read_text())
981
+ except (json.JSONDecodeError, OSError):
982
+ pass
983
+
984
+ out({
985
+ "running": running,
986
+ "pid": pid,
987
+ "status": state.get("status", "unknown"),
988
+ "restart_count": state.get("restart_count", 0),
989
+ "consecutive_fails": state.get("consecutive_fails", 0),
990
+ "last_restart": state.get("last_restart", ""),
991
+ "last_check": state.get("last_check", ""),
992
+ "message": state.get("message", ""),
993
+ }, args.pretty)
994
+
995
+
996
+ def cmd_job_text(job_id, pretty=False, raw=False):
997
+ """작업의 최종 텍스트 결과만 추출 (이벤트 스트림 없이)."""
998
+ result, error = get_job_result(job_id)
999
+ if error:
1000
+ err(error)
1001
+
1002
+ text = result.get("result") if result else None
1003
+ if text is None:
1004
+ # result 없으면 .out 에서 text 이벤트를 합산
1005
+ out_file = LOGS_DIR / f"job_{job_id}.out"
1006
+ if out_file.exists():
1007
+ parts = []
1008
+ try:
1009
+ with open(out_file, "r") as f:
1010
+ for raw_line in f:
1011
+ if '"type":"assistant"' not in raw_line:
1012
+ continue
1013
+ try:
1014
+ evt = json.loads(raw_line)
1015
+ if evt.get("type") == "assistant":
1016
+ content = evt.get("message", {}).get("content", [])
1017
+ for c in content:
1018
+ if c.get("type") == "text" and c.get("text"):
1019
+ parts.append(c["text"])
1020
+ except json.JSONDecodeError:
1021
+ continue
1022
+ except OSError:
1023
+ pass
1024
+ text = "\n".join(parts) if parts else None
1025
+
1026
+ if raw:
1027
+ # --raw: JSON 래핑 없이 텍스트만 stdout에 출력
1028
+ print(text or "")
1029
+ return
1030
+
1031
+ out({
1032
+ "job_id": job_id,
1033
+ "status": result.get("status") if result else "unknown",
1034
+ "text": text,
1035
+ "cost_usd": result.get("cost_usd") if result else None,
1036
+ "session_id": result.get("session_id") if result else None,
1037
+ }, pretty)
737
1038
 
738
1039
 
739
1040
  # ══════════════════════════════════════════════════════════════
@@ -771,11 +1072,12 @@ def build_parser():
771
1072
  p_job = sub.add_parser("job", parents=[common], help="단일 작업 조작")
772
1073
  p_job.add_argument("job_id", help="작업 ID")
773
1074
  p_job.add_argument("action", nargs="?",
774
- choices=["stream", "checkpoints", "rewind", "delete"],
775
- help="수행할 작업 (생략 시 결과 조회)")
1075
+ choices=["stream", "checkpoints", "rewind", "delete", "text"],
1076
+ help="수행할 작업 (생략 시 결과 조회, text: 최종 텍스트만)")
776
1077
  p_job.add_argument("--offset", type=int, default=0, help="스트림 오프셋 (stream용)")
777
1078
  p_job.add_argument("--checkpoint", help="체크포인트 해시 (rewind용)")
778
1079
  p_job.add_argument("--prompt", dest="prompt", help="리와인드 프롬프트 (rewind용)")
1080
+ p_job.add_argument("--raw", action="store_true", help="텍스트만 출력 (text 액션용, JSON 래핑 없이)")
779
1081
 
780
1082
  # ── sessions ──
781
1083
  p_sessions = sub.add_parser("sessions", parents=[common], help="세션 목록 조회")
@@ -815,10 +1117,26 @@ def build_parser():
815
1117
  p_proj.add_argument("--desc", help="프로젝트 설명")
816
1118
  p_proj.add_argument("--no-git", action="store_true", help="git init 건너뛰기 (create용)")
817
1119
 
1120
+ # ── summary (AI 컨텍스트) ──
1121
+ sub.add_parser("summary", parents=[common], help="서비스 + 작업 + 파이프라인 전체 요약 (AI용)")
1122
+
1123
+ # ── stats ──
1124
+ p_stats = sub.add_parser("stats", parents=[common], help="작업 통계 집계")
1125
+ p_stats.add_argument("--limit", "-n", type=int, help="최근 N개 작업만 집계")
1126
+
1127
+ # ── health ──
1128
+ sub.add_parser("health", parents=[common], help="서비스 헬스체크 + 디스크 + stuck 작업")
1129
+
1130
+ # ── watchdog ──
1131
+ p_wd = sub.add_parser("watchdog", parents=[common], help="프로세스 워치독 관리")
1132
+ p_wd.add_argument("action", nargs="?",
1133
+ choices=["start", "stop", "status", "install", "uninstall"],
1134
+ help="수행할 작업 (생략 시 status)")
1135
+
818
1136
  # ── pipeline ──
819
1137
  p_pipe = sub.add_parser("pipeline", parents=[common], help="파이프라인 관리/실행")
820
1138
  p_pipe.add_argument("action", nargs="?",
821
- choices=["list", "create", "status", "run", "tick", "tick-all", "reset", "delete"],
1139
+ choices=["list", "create", "status", "run", "tick", "tick-all", "reset", "delete", "evolution"],
822
1140
  help="수행할 작업 (생략 시 목록)")
823
1141
  p_pipe.add_argument("path", nargs="?", help="프로젝트 경로 (하위 호환)")
824
1142
  p_pipe.add_argument("--project", "-p", help="프로젝트 이름 또는 ID (create용)")
@@ -849,6 +1167,10 @@ DISPATCH = {
849
1167
  "upload": cmd_upload,
850
1168
  "project": cmd_project,
851
1169
  "pipeline": cmd_pipeline,
1170
+ "summary": cmd_summary,
1171
+ "stats": cmd_stats,
1172
+ "health": cmd_health,
1173
+ "watchdog": cmd_watchdog,
852
1174
  }
853
1175
 
854
1176
 
package/bin/native-app.py CHANGED
@@ -61,7 +61,7 @@ def main():
61
61
  use_ssl = os.path.isfile(SSL_CERT) and os.path.isfile(SSL_KEY)
62
62
  scheme = "https" if use_ssl else "http"
63
63
 
64
- server = http.server.HTTPServer(("127.0.0.1", PORT), ControllerHandler)
64
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), ControllerHandler)
65
65
 
66
66
  if use_ssl:
67
67
  ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
@@ -89,7 +89,10 @@ def main():
89
89
  print(f" mkcert -install && mkcert -cert-file certs/localhost+1.pem \\")
90
90
  print(f" -key-file certs/localhost+1-key.pem localhost 127.0.0.1\n")
91
91
 
92
- webbrowser.open(PUBLIC_URL)
92
+ try:
93
+ webbrowser.open(PUBLIC_URL)
94
+ except Exception:
95
+ pass # Docker 등 headless 환경에서는 브라우저 없음
93
96
 
94
97
  try:
95
98
  server.serve_forever()