Justin-lee commited on
Commit
d13d68e
·
verified ·
1 Parent(s): d44b9cb

P0+P1 complete: approval, background tasks, custom agents, auto git commit

Browse files
Files changed (1) hide show
  1. codepilot_v4.py +316 -4
codepilot_v4.py CHANGED
@@ -645,7 +645,6 @@ class Hooks:
645
  cmd_template = self.hooks.get(event)
646
  if not cmd_template:
647
  return None
648
- # 替換變數
649
  cmd = cmd_template
650
  if context:
651
  for k, v in context.items():
@@ -658,6 +657,230 @@ class Hooks:
658
  return None
659
 
660
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
661
  def build_system_prompt(tools, project_memory=""):
662
  memory_section = f"\n\n## Project Memory (CODEPILOT.md)\n{project_memory}" if project_memory else ""
663
  return f"""You are CodePilot, an expert AI programming assistant working in the user's project.
@@ -873,10 +1096,16 @@ def run_agent_loop(args):
873
  else:
874
  messages = [{"role": "system", "content": system_prompt}]
875
 
876
- hooks = Hooks(project_dir) # P0-Bonus: Hooks 系統
877
- edited_files_this_session = [] # 追蹤修改過的文件
 
 
 
 
 
 
878
 
879
- console.print("[dim]/init /duel on|off /memo /verify /grind /ls /git /clear /status /train /quit[/]\n")
880
 
881
  while True:
882
  try: user_input = Prompt.ask("\n[bold green]🧑 You")
@@ -954,6 +1183,81 @@ def run_agent_loop(args):
954
  n = Prompt.ask("刷幾題?", default="50")
955
  run_grind(args, int(n)); continue
956
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
957
  elif cmd == "/status":
958
  s = db.count()
959
  t = Table(title="📊 統計"); t.add_column("", style="cyan"); t.add_column("", style="green")
@@ -1057,6 +1361,12 @@ def run_agent_loop(args):
1057
  results = []
1058
  for call in tool_calls:
1059
  console.print(f" [dim]🔧 {call['tool']}[/]")
 
 
 
 
 
 
1060
  result = execute_tool(tools, call) # 已含 P0-2 截斷
1061
  tools_used_this_turn.append(call["tool"])
1062
 
@@ -1168,6 +1478,8 @@ def main():
1168
  help="模型: local, openai, anthropic, openrouter, ollama, codex")
1169
  p.add_argument("--api-key", type=str); p.add_argument("--cloud-model", type=str)
1170
  p.add_argument("--duel", action="store_true", help="啟動時開啟 Duel 模式")
 
 
1171
  p.add_argument("--distill", action="store_true")
1172
  p.add_argument("--grind", action="store_true", help="LeetCode 自動刷題")
1173
  p.add_argument("--grind-count", type=int, default=100, help="刷幾題")
 
645
  cmd_template = self.hooks.get(event)
646
  if not cmd_template:
647
  return None
 
648
  cmd = cmd_template
649
  if context:
650
  for k, v in context.items():
 
657
  return None
658
 
659
 
660
+ # ============================================================
661
+ # P1-1: APPROVAL SYSTEM(權限/審批)
662
+ # ============================================================
663
+ APPROVAL_MODES = {
664
+ "auto": "全自動(只擋危險指令)",
665
+ "auto-edit": "文件修改自動,shell 指令要確認",
666
+ "ask": "每次工具呼叫都確認",
667
+ }
668
+
669
+ # 不需要確認的工具(只讀)
670
+ SAFE_TOOLS = {"read_file", "search_files", "list_files", "git_status"}
671
+
672
+ def check_approval(tool_name, params, approval_mode, console):
673
+ """檢查工具是否需要用戶確認。回傳 True = 允許, False = 拒絕"""
674
+ if approval_mode == "auto":
675
+ return True # 全自動(危險指令在 run_command 裡已經擋了)
676
+ if tool_name in SAFE_TOOLS:
677
+ return True # 只讀工具永遠通過
678
+ if approval_mode == "auto-edit" and tool_name in ("edit_file", "write_file"):
679
+ return True # auto-edit 模式下文件修改自動通過
680
+
681
+ # 需要用戶確認
682
+ from rich.prompt import Confirm
683
+ param_preview = json.dumps(params, ensure_ascii=False)[:120]
684
+ console.print(f" [yellow]⚠️ {tool_name}({param_preview})[/]")
685
+ return Confirm.ask(" 允許執行?", default=True)
686
+
687
+
688
+ # ============================================================
689
+ # P1-2: BACKGROUND TASKS(背景任務管理)
690
+ # ============================================================
691
+ import threading, uuid as _uuid
692
+
693
+ class BackgroundTaskManager:
694
+ """背景任務管理器 — 長時間指令不阻塞主循環"""
695
+
696
+ def __init__(self):
697
+ self._tasks = {} # id → {process, command, start_time, output}
698
+
699
+ def start(self, command, cwd):
700
+ """啟動背景任務"""
701
+ task_id = str(_uuid.uuid4())[:6]
702
+ proc = subprocess.Popen(
703
+ command, shell=True, cwd=cwd,
704
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
705
+ text=True)
706
+ self._tasks[task_id] = {
707
+ "process": proc, "command": command,
708
+ "start_time": datetime.now(), "output_lines": []
709
+ }
710
+ # 背景讀取輸出
711
+ def _reader():
712
+ for line in proc.stdout:
713
+ self._tasks[task_id]["output_lines"].append(line)
714
+ threading.Thread(target=_reader, daemon=True).start()
715
+ return task_id
716
+
717
+ def check(self, task_id):
718
+ """檢查任務狀態"""
719
+ t = self._tasks.get(task_id)
720
+ if not t: return {"status": "not_found"}
721
+ running = t["process"].poll() is None
722
+ elapsed = (datetime.now() - t["start_time"]).seconds
723
+ output = "".join(t["output_lines"][-20:]) # 最後 20 行
724
+ return {
725
+ "status": "running" if running else "done",
726
+ "exit_code": t["process"].returncode,
727
+ "elapsed": elapsed,
728
+ "output": output,
729
+ "command": t["command"],
730
+ }
731
+
732
+ def list_tasks(self):
733
+ """列出所有背景任務"""
734
+ results = []
735
+ for tid, t in self._tasks.items():
736
+ running = t["process"].poll() is None
737
+ elapsed = (datetime.now() - t["start_time"]).seconds
738
+ results.append(f" {'🟢' if running else '⚫'} {tid}: {t['command'][:50]} ({elapsed}s)")
739
+ return "\n".join(results) if results else " (無背景任務)"
740
+
741
+ def kill(self, task_id):
742
+ """終止任務"""
743
+ t = self._tasks.get(task_id)
744
+ if t and t["process"].poll() is None:
745
+ t["process"].kill()
746
+ return True
747
+ return False
748
+
749
+
750
+ # ============================================================
751
+ # P1-3: CUSTOM AGENTS(自訂代理 .codepilot/agents/*.md)
752
+ # ============================================================
753
+ def load_custom_agents(project_dir):
754
+ """載入 .codepilot/agents/*.md 自訂代理"""
755
+ agents_dir = os.path.join(project_dir, ".codepilot", "agents")
756
+ agents = {}
757
+ if not os.path.isdir(agents_dir):
758
+ return agents
759
+ for f in sorted(Path(agents_dir).glob("*.md")):
760
+ content = f.read_text(encoding="utf-8")
761
+ name = f.stem
762
+ # 解析 YAML frontmatter
763
+ config = {"name": name, "prompt": content}
764
+ if content.startswith("---"):
765
+ parts = content.split("---", 2)
766
+ if len(parts) >= 3:
767
+ try:
768
+ # 簡易 YAML 解析
769
+ for line in parts[1].strip().split("\n"):
770
+ if ":" in line:
771
+ k, v = line.split(":", 1)
772
+ k, v = k.strip(), v.strip()
773
+ if v.startswith("[") and v.endswith("]"):
774
+ v = [x.strip().strip("'\"") for x in v[1:-1].split(",")]
775
+ config[k] = v
776
+ except: pass
777
+ config["prompt"] = parts[2].strip()
778
+ agents[name] = config
779
+ return agents
780
+
781
+
782
+ def run_custom_agent(agent_config, user_task, model, tools, console):
783
+ """執行自訂代理"""
784
+ from rich.markdown import Markdown
785
+ name = agent_config["name"]
786
+ prompt = agent_config["prompt"]
787
+ allowed = agent_config.get("tools") # list or None
788
+ denied = agent_config.get("disallowedTools", [])
789
+
790
+ console.print(f"\n[bold magenta]🤖 Agent: {name}[/]")
791
+
792
+ agent_messages = [
793
+ {"role": "system", "content": prompt},
794
+ {"role": "user", "content": user_task},
795
+ ]
796
+
797
+ full_response = ""
798
+ for rnd in range(5): # 子代理最多 5 輪
799
+ with console.status(f"[magenta]{name} 思考中 (round {rnd+1})..."):
800
+ try: response = model.chat(agent_messages)
801
+ except Exception as e: console.print(f"[red]❌ {e}[/]"); break
802
+
803
+ tool_calls = parse_tool_calls(response)
804
+ text_parts = TOOL_PATTERN.sub("", response).strip()
805
+ if text_parts:
806
+ console.print(f" [magenta][{name}][/] {text_parts[:300]}")
807
+ full_response += response + "\n"
808
+ if not tool_calls: break
809
+
810
+ agent_messages.append({"role": "assistant", "content": response})
811
+ results = []
812
+ for call in tool_calls:
813
+ # 權限檢查
814
+ if allowed and call["tool"] not in allowed:
815
+ results.append(f"[{call['tool']}] ❌ 此代理不允許使用 {call['tool']}")
816
+ continue
817
+ if call["tool"] in denied:
818
+ results.append(f"[{call['tool']}] ❌ 此代理禁止使用 {call['tool']}")
819
+ continue
820
+ result = execute_tool(tools, call)
821
+ results.append(f"[{call['tool']}] {result}")
822
+ agent_messages.append({"role": "user", "content": "Tool results:\n" + "\n\n".join(results)})
823
+
824
+ return full_response
825
+
826
+
827
+ # ============================================================
828
+ # P1-4: AUTO GIT COMMIT
829
+ # ============================================================
830
+ def auto_git_commit(tools, model, edited_files, console):
831
+ """自動 stage 修改的文件並 commit"""
832
+ if not edited_files:
833
+ console.print("[dim]沒有修改的文件[/]")
834
+ return
835
+
836
+ # 只 stage 明確修改過的文件(不用 git add -A)
837
+ rel_files = []
838
+ for f in edited_files:
839
+ try:
840
+ rel = os.path.relpath(f, tools.project_dir)
841
+ rel_files.append(rel)
842
+ except: continue
843
+
844
+ if not rel_files:
845
+ return
846
+
847
+ console.print(f" [dim]📁 Stage: {', '.join(rel_files[:5])}{'...' if len(rel_files)>5 else ''}[/]")
848
+
849
+ # git add 個別文件
850
+ for f in rel_files:
851
+ subprocess.run(["git", "add", f], cwd=tools.project_dir, capture_output=True)
852
+
853
+ # 用模型生成 commit message
854
+ diff = subprocess.run(["git", "diff", "--cached", "--stat"],
855
+ cwd=tools.project_dir, capture_output=True, text=True).stdout
856
+
857
+ with console.status("[dim]生成 commit message..."):
858
+ msg_prompt = f"Generate a concise git commit message (1 line, max 72 chars) for:\n\n{diff[:2000]}"
859
+ try:
860
+ commit_msg = model.chat([{"role": "user", "content": msg_prompt}], max_tokens=100)
861
+ # 清理:取第一行,去掉引號
862
+ commit_msg = commit_msg.strip().split("\n")[0].strip('"').strip("'")
863
+ if len(commit_msg) > 72: commit_msg = commit_msg[:69] + "..."
864
+ except:
865
+ commit_msg = f"codepilot: update {len(rel_files)} file(s)"
866
+
867
+ console.print(f" [dim]💬 {commit_msg}[/]")
868
+
869
+ from rich.prompt import Confirm
870
+ if Confirm.ask(" Commit?", default=True):
871
+ result = subprocess.run(["git", "commit", "-m", commit_msg],
872
+ cwd=tools.project_dir, capture_output=True, text=True)
873
+ if result.returncode == 0:
874
+ console.print(f" [green]✅ Committed[/]")
875
+ else:
876
+ console.print(f" [red]❌ {result.stderr[:200]}[/]")
877
+ else:
878
+ # unstage
879
+ subprocess.run(["git", "reset", "HEAD"] + rel_files,
880
+ cwd=tools.project_dir, capture_output=True)
881
+ console.print(" [dim]已取消[/]")
882
+
883
+
884
  def build_system_prompt(tools, project_memory=""):
885
  memory_section = f"\n\n## Project Memory (CODEPILOT.md)\n{project_memory}" if project_memory else ""
886
  return f"""You are CodePilot, an expert AI programming assistant working in the user's project.
 
1096
  else:
1097
  messages = [{"role": "system", "content": system_prompt}]
1098
 
1099
+ hooks = Hooks(project_dir)
1100
+ bg_tasks = BackgroundTaskManager() # P1-2: 背景任務
1101
+ custom_agents = load_custom_agents(project_dir) # P1-3: 自訂代理
1102
+ approval_mode = args.approval or "auto" # P1-1: 審批模式
1103
+ edited_files_this_session = []
1104
+
1105
+ if custom_agents:
1106
+ console.print(f"[dim]🤖 自訂���理: {', '.join(custom_agents.keys())}[/]")
1107
 
1108
+ console.print(f"[dim]指令: /init /verify /commit /agent /bg /approval | /duel /memo /grind /ls /git /clear /status /train /quit[/]\n")
1109
 
1110
  while True:
1111
  try: user_input = Prompt.ask("\n[bold green]🧑 You")
 
1183
  n = Prompt.ask("刷幾題?", default="50")
1184
  run_grind(args, int(n)); continue
1185
 
1186
+ elif cmd == "/commit":
1187
+ # P1-4: 自動 git commit
1188
+ auto_git_commit(tools, model, edited_files_this_session, console)
1189
+ continue
1190
+
1191
+ elif cmd.startswith("/agent"):
1192
+ # P1-3: 自訂代理
1193
+ parts = cmd.split(None, 2)
1194
+ if len(parts) < 2:
1195
+ console.print("[bold]可用代理:[/]")
1196
+ if custom_agents:
1197
+ for name, cfg in custom_agents.items():
1198
+ desc = cfg.get("description", "")
1199
+ console.print(f" 🤖 {name}: {desc}")
1200
+ console.print(f"\n[dim]用法: /agent <名稱> <任務>[/]")
1201
+ else:
1202
+ console.print("[dim]無自訂代理。建立 .codepilot/agents/*.md[/]")
1203
+ console.print("[dim]範例: .codepilot/agents/reviewer.md[/]")
1204
+ continue
1205
+ agent_name = parts[1]
1206
+ agent_task = parts[2] if len(parts) > 2 else Prompt.ask("任務")
1207
+ if agent_name in custom_agents:
1208
+ result = run_custom_agent(custom_agents[agent_name], agent_task, model, tools, console)
1209
+ elif agent_name == "explore":
1210
+ # 內建 Explore agent(只讀)
1211
+ result = run_custom_agent(
1212
+ {"name": "explore", "prompt": "You are an exploration agent. Read and search files to investigate. NEVER modify or create files.",
1213
+ "tools": ["read_file", "search_files", "list_files", "git_status"]},
1214
+ agent_task, model, tools, console)
1215
+ elif agent_name == "plan":
1216
+ # 內建 Plan agent
1217
+ result = run_custom_agent(
1218
+ {"name": "plan", "prompt": "You are a planning agent. Analyze the task and create a detailed step-by-step plan. Do NOT execute any changes.",
1219
+ "tools": ["read_file", "search_files", "list_files", "git_status"]},
1220
+ agent_task, model, tools, console)
1221
+ else:
1222
+ console.print(f"[red]未知代理: {agent_name}[/]")
1223
+ console.print(f"[dim]可用: {', '.join(list(custom_agents.keys()) + ['explore', 'plan'])}[/]")
1224
+ continue
1225
+
1226
+ elif cmd.startswith("/bg"):
1227
+ # P1-2: 背景任務
1228
+ parts = cmd.split(None, 1)
1229
+ if len(parts) < 2 or parts[1] == "list":
1230
+ console.print(bg_tasks.list_tasks())
1231
+ elif parts[1].startswith("run "):
1232
+ bg_cmd = parts[1][4:]
1233
+ tid = bg_tasks.start(bg_cmd, tools.cwd)
1234
+ console.print(f" [green]🚀 背景任務 {tid}: {bg_cmd}[/]")
1235
+ elif parts[1].startswith("check "):
1236
+ tid = parts[1][6:].strip()
1237
+ info = bg_tasks.check(tid)
1238
+ console.print(f" 狀態: {info['status']} | 耗時: {info.get('elapsed',0)}s")
1239
+ if info.get("output"): console.print(Panel(info["output"][:500], title=f"bg:{tid}", border_style="dim"))
1240
+ elif parts[1].startswith("kill "):
1241
+ tid = parts[1][5:].strip()
1242
+ if bg_tasks.kill(tid): console.print(f" [red]⛔ 已終止 {tid}[/]")
1243
+ else: console.print(f" [dim]任務不存在或已結束[/]")
1244
+ else:
1245
+ console.print("[dim]/bg list | /bg run <cmd> | /bg check <id> | /bg kill <id>[/]")
1246
+ continue
1247
+
1248
+ elif cmd.startswith("/approval"):
1249
+ # P1-1: 切換審批模式
1250
+ parts = cmd.split()
1251
+ if len(parts) > 1 and parts[1] in APPROVAL_MODES:
1252
+ approval_mode = parts[1]
1253
+ console.print(f" [green]審批模式: {approval_mode} — {APPROVAL_MODES[approval_mode]}[/]")
1254
+ else:
1255
+ console.print(f" 目前: [bold]{approval_mode}[/] — {APPROVAL_MODES.get(approval_mode,'')}")
1256
+ for k, v in APPROVAL_MODES.items():
1257
+ marker = "→" if k == approval_mode else " "
1258
+ console.print(f" {marker} /approval {k}: {v}")
1259
+ continue
1260
+
1261
  elif cmd == "/status":
1262
  s = db.count()
1263
  t = Table(title="📊 統計"); t.add_column("", style="cyan"); t.add_column("", style="green")
 
1361
  results = []
1362
  for call in tool_calls:
1363
  console.print(f" [dim]🔧 {call['tool']}[/]")
1364
+
1365
+ # P1-1: 審批檢查
1366
+ if not check_approval(call["tool"], call["params"], approval_mode, console):
1367
+ results.append(f"[{call['tool']}] ⛔ 用戶拒絕執行")
1368
+ continue
1369
+
1370
  result = execute_tool(tools, call) # 已含 P0-2 截斷
1371
  tools_used_this_turn.append(call["tool"])
1372
 
 
1478
  help="模型: local, openai, anthropic, openrouter, ollama, codex")
1479
  p.add_argument("--api-key", type=str); p.add_argument("--cloud-model", type=str)
1480
  p.add_argument("--duel", action="store_true", help="啟動時開啟 Duel 模式")
1481
+ p.add_argument("--approval", type=str, choices=["auto","auto-edit","ask"], default="auto",
1482
+ help="審批模式: auto=全自動, auto-edit=指令要確認, ask=全部確認")
1483
  p.add_argument("--distill", action="store_true")
1484
  p.add_argument("--grind", action="store_true", help="LeetCode 自動刷題")
1485
  p.add_argument("--grind-count", type=int, default=100, help="刷幾題")