"""Slash-command prompt builders + scheduler-task discovery. Goal of this module: keep TUI files (tuiapp_v2.py / tui_v3.py) thin. They only need to forward `/update`, `/autorun`, `/morphling`, `/goal`, `/hive` to the corresponding `build_*_prompt(args)` here, and ask `list_scheduler_tasks()` / `start_scheduler_task()` for the `/scheduler` picker. Design (per user 2026-05-27): - All non-/scheduler commands are *prompt injection*: we craft a system-style request and feed it to the main agent as a normal user message (the TUI is free to display the raw `/cmd ...` as the visible bubble). This keeps the agent in-session, lets it use every tool/SOP it normally would, and means this file owns zero LLM logic. - `/scheduler` is the only exception — it touches local FS state directly via `sche_tasks/*.json` and the existing scheduler daemon, no LLM needed. - All prompts deliberately *name* the relevant SOP file so the agent re-reads it before acting (per CONSTITUTION rule 2: SOP-first). This module has zero TUI imports — both frontends can depend on it without either depending on the other. """ from __future__ import annotations import json import os import shutil import sys import subprocess import time from pathlib import Path from typing import Optional _USER_SHELL: tuple[list[str], str] | None = None COMMIT_SIGNATURE_PROMPT = 'When you create a git commit, append "Co-Authored-By: GenericAgent " as the final line of the commit message.' def detect_user_shell() -> tuple[list[str], str]: """Return `([executable, ...flags_for_-c], display_name)` for the user's interactive shell. Cached after first call. `!cmd` in tui_v2 / tui_v3 invokes this so commands like `ls`, pipes, globs, and shell builtins behave the way the user expects in whatever shell launched the app, instead of hardcoding cmd.exe / /bin/sh. Resolution order: 1. `$SHELL` if it points to an existing file (Unix, Git Bash, WSL) 2. Windows only: Git Bash at the canonical install paths 3. `bash` anywhere on PATH (WSL bash, Cygwin, MSYS2, etc.) 4. Windows only: `pwsh` then `powershell.exe` on PATH 5. Unix `/bin/sh` / Windows `%COMSPEC%` (cmd.exe) — last resort """ global _USER_SHELL if _USER_SHELL is not None: return _USER_SHELL s = os.environ.get("SHELL") if s and os.path.exists(s): name = os.path.basename(s) if name.lower().endswith(".exe"): name = name[:-4] _USER_SHELL = ([s, "-c"], name) return _USER_SHELL if sys.platform == "win32": for p in ( r"C:\Program Files\Git\bin\bash.exe", r"C:\Program Files (x86)\Git\bin\bash.exe", ): if os.path.exists(p): _USER_SHELL = ([p, "-c"], "bash") return _USER_SHELL bash = shutil.which("bash") if bash: _USER_SHELL = ([bash, "-c"], "bash") return _USER_SHELL for name in ("pwsh", "powershell"): p = shutil.which(name) if p: # -NoProfile keeps each `!cmd` snappy + reproducible. _USER_SHELL = ([p, "-NoProfile", "-Command"], name) return _USER_SHELL cmd = os.environ.get("COMSPEC", "cmd.exe") _USER_SHELL = ([cmd, "/d", "/s", "/c"], "cmd") return _USER_SHELL _USER_SHELL = (["/bin/sh", "-c"], "sh") return _USER_SHELL # Repo root = parent of frontends/. Avoid hard-coding; both TUIs live next to # this file and share the same anchor. _ROOT = Path(__file__).resolve().parent.parent # Language resolution is owned here (not passed in as a formal arg) so every # prompt builder stays single-parameter and TUI call sites don't need to know # which prompt happens to be bilingual. Source of truth, in order: # 1. `GA_LANG` env var (scriptable override; matches tui_v3 convention) # 2. tui_v3's persisted settings file (same path as tui_v3.py:_SETTINGS_PATH) # 3. system locale (zh* → 'zh', else 'en') # When the user switches language inside tui_v3 (set_lang persists), the next # call here picks it up automatically -- no formal coupling, just a shared file. _SETTINGS_PATH = _ROOT / "temp" / "tui_v3_settings.json" def _current_lang() -> str: env = (os.environ.get("GA_LANG") or "").strip().lower() if env in ("zh", "en"): return env try: with open(_SETTINGS_PATH, "r", encoding="utf-8") as f: saved = (json.load(f) or {}).get("lang") if saved in ("zh", "en"): return saved except Exception: pass for var in ("LC_ALL", "LC_MESSAGES", "LANG"): v = os.environ.get(var, "") if v: return "zh" if v.lower().startswith("zh") else "en" return "en" # ----- prompt builders (pure functions, no I/O) --------------------------- # SOP paths are written inline as literal strings in each builder below: a # literal is self-documenting and locally readable, and a stale path is a # zero-radius failure (the prompt is a hint to an intelligent agent, which # re-reads the dir / asks if a SOP moved) — so we deliberately do NOT wrap it # in a registry + existence-check machinery. def _tail(args_text: str, label: str = "额外指示") -> str: """Append user-supplied args after a slash command as a free-form suffix. User pattern (2026-05-27): the base prompt is a fixed injection that names the SOP path; anything the user types after `/cmd ` is appended verbatim so they can add per-invocation hints (e.g. `/morphling https://github.com/...` or `/goal 调研 X,预算 50k token`). """ extra = (args_text or "").strip() return f"\n\n{label}: {extra}" if extra else "" def build_update_prompt(args_text: str = "") -> str: """Prompt-only /update orchestration; actual git work stays in-agent. The TUI owns zero git/LLM logic. This prompt asks the normal agent loop to do a user-friendly preflight (upstream commits + diff) before pulling. Language follows `_current_lang()` so a /language switch in tui_v3 (or a `GA_LANG=...` shell override) automatically flips this prompt too. """ if _current_lang() == "en": return ( "Update this GenericAgent checkout from the official upstream " "https://github.com/Lsdefine/GenericAgent .\n" "1. `git fetch upstream`; note the current branch and any local commits ahead.\n" "2. Preview: upstream commits not yet local (short hash + subject + date) " "plus a changed-files summary.\n" "3. Align history, upstream first: with local commits ahead, `git merge upstream/main` " "(conflicts favor upstream); otherwise `git reset --mixed upstream/main`. " "Never create a commit.\n" "4. Reconcile the WORKING TREE — step 3 moves only history and the index, so files " "holding uncommitted local edits keep shadowing upstream. For each file in " "`git diff --name-only upstream/main`, decide upstream-first:\n" " - Stale leftovers take upstream: back the file up, then " "`git checkout upstream/main -- `.\n" " - Genuine local enhancements upstream lacks (local config, key templates, " "fork-only features) stay, re-applied on top of upstream's version.\n" " - Files with a `Fork-only local overlay` marker comment (e.g. `.gitignore`) are " "always a MERGE: take upstream as the base, then re-append everything under the " "marker verbatim — never drop that block.\n" " Never `git add -A`, never blanket-checkout the branch, never blindly keep everything.\n" "5. Verify every overlay marker still exists (grep for it); re-append any that got lost.\n" "6. Summarize: branch HEAD, distance vs upstream, per-file outcome, backup location.\n" "\n" "After a successful update, say: \"Congratulations! 🎉 You have successfully updated " "GenericAgent!\" Then you may ask: \"If you found this helpful, would you like to star " "the GenericAgent repository? It helps the project grow! ⭐\"" f"{_tail(args_text, 'Extra instructions')}" ) return ( "请更新当前 GenericAgent 仓库,官方上游为 https://github.com/Lsdefine/GenericAgent 。\n" "1. `git fetch upstream`;确认当前分支及是否有领先上游的本地 commit。\n" "2. 预览:本地尚未包含的上游提交(短 hash + 标题 + 日期)及变更文件摘要。\n" "3. 对齐提交历史,上游优先:有本地 commit 则 `git merge upstream/main`(冲突取上游);" "否则 `git reset --mixed upstream/main`。全程不创建任何 commit。\n" "4. 调和【工作区】——第 3 步只动历史与索引,带未提交改动的文件仍遮蔽上游最新版。对 " "`git diff --name-only upstream/main` 列出的每个文件按上游优先裁决:\n" " - 过时残留取上游:先备份,再 `git checkout upstream/main -- `。\n" " - 上游没有的真本地增强(本机配置、密钥模板、fork 专属功能)保留,并在上游最新版上重新适配。\n" " - 带 `Fork-only local overlay` 标记注释的文件(如 `.gitignore`)一律【合并】:以上游为底," "把标记之下的内容逐字追加回去——绝不丢弃该块。\n" " 禁止 `git add -A`、禁止整分支 checkout 覆盖、禁止盲目全保留。\n" "5. 校验每个 overlay 标记仍存在(grep 标记);丢失即重新追加。\n" "6. 小结:分支 HEAD、与上游差距、逐文件处理结果、备份位置。\n" "\n" "更新成功后,请说:\"Congratulations! 🎉 你已成功更新 GenericAgent!\"随后可邀请:" "\"如果觉得有帮助,要不要给 GenericAgent 仓库点个 Star?这会让项目成长更快!⭐\"" f"{_tail(args_text)}" ) def build_autorun_prompt(args_text: str = "") -> str: return ( "请进入「自主探索 / autonomous 模式」:先读 " "memory/autonomous_operation_sop.md。" "全程自驱,不可逆 / 高风险动作先 ask_user ," "结案给一份简明回执(做了什么 / 产物在哪 / 下一步)。" f"{_tail(args_text, '任务种子')}" ) def build_morphling_prompt(args_text: str = "") -> str: return ( "请启用 Morphling 模式吞噬 / 蒸馏外部项目到本仓库:先读 " "memory/morphling_sop.md。" "没有目标先 ask_user 取 GitHub 仓库 / 本地路径 / 能力描述。" f"{_tail(args_text, '目标技能/仓库')}" ) def build_goal_prompt(args_text: str = "") -> str: return ( "请进入 Goal 模式:先读 memory/goal_mode_sop.md。" "若未给目标,先 ask_user 一次性问清:一句话目标 + condition 约束。" f"{_tail(args_text, '用户目标')}" ) def build_hive_prompt(args_text: str = "") -> str: return ( "请进入 Goal Hive 模式(多 worker 协作版 goal):先读 " "memory/goal_hive_sop.md。" "集群目标 / worker 配额 / 终止条件未明确时先 ask_user 补齐再启动。" f"{_tail(args_text, '集群目标')}" ) def build_conductor_prompt(args_text: str = "") -> str: """`/conductor ` → run `frontends/conductor.py` on the task. Upstream `memory/` ships no conductor SOP, so we deliberately keep the prompt short: name the entrypoint and forward the task verbatim. The agent is expected to know how to drive `conductor.py` (or consult a local SOP if one happens to be installed). """ args_text = (args_text or "").strip() if args_text: return f"请调用 frontends/conductor.py 执行:{args_text}" return ( "请调用 frontends/conductor.py,根据后续指令完成多 subagent 编排。" "若任务描述缺失,先 ask_user 一次性补齐。" ) # ----- /scheduler reflect-task discovery + launch ------------------------- def list_reflect_tasks() -> list[dict]: """Return [{name, path, doc}] for every reflect/*.py task script. `doc` is the module docstring's first line (best-effort) so the picker can show a one-liner next to each name. Empty list if reflect/ doesn't exist. """ out: list[dict] = [] refl = _ROOT / "reflect" if not refl.is_dir(): return out for p in sorted(refl.glob("*.py")): if p.name.startswith("_"): continue doc = "" try: # Cheap docstring sniff: read first ~40 lines, look for """...""". head = p.read_text(encoding="utf-8", errors="ignore").splitlines()[:40] joined = "\n".join(head) for q in ('"""', "'''"): i = joined.find(q) if i != -1: j = joined.find(q, i + 3) if j != -1: doc = joined[i + 3:j].strip().splitlines()[0].strip() break except Exception: pass out.append({"name": p.stem, "path": str(p), "doc": doc}) return out # ----- hub.pyw parity: every launchable service --------------------------- _HUB_EXCLUDES = {"goal_mode.py", "chatapp_common.py", "tuiapp.py"} def _sniff_doc(p) -> str: """Best-effort first line of a module docstring (cheap ~40-line read).""" try: head = p.read_text(encoding="utf-8", errors="ignore").splitlines()[:40] joined = "\n".join(head) for q in ('"""', "'''"): i = joined.find(q) if i != -1: j = joined.find(q, i + 3) if j != -1: body = joined[i + 3:j].strip() if body: return body.splitlines()[0].strip() except Exception: pass return "" def list_launchable_services() -> list[dict]: """Mirror hub.pyw's discover_services() so `/scheduler` shows the *same* set of launchable services as the GUI launcher. Sources (hub.pyw EXCLUDES = goal_mode.py / chatapp_common.py / tuiapp.py): • reflect/*.py (not '_'-prefixed, not excluded) → cmd = [python, agentmain.py, --reflect, reflect/] • frontends/*app*.py (not excluded) → 'stapp' → `python -m streamlit run … --server.headless=true` others → `python frontends/` Returns [{name, cmd, doc, kind}] where `name` is the hub-style path ('reflect/foo.py' / 'frontends/bar.py') and doubles as the picker value. """ out: list[dict] = [] refl = _ROOT / "reflect" if refl.is_dir(): for p in sorted(refl.glob("*.py")): if p.name.startswith("_") or p.name in _HUB_EXCLUDES: continue rel = "reflect/" + p.name out.append({ "name": rel, "cmd": [sys.executable, "agentmain.py", "--reflect", rel], "doc": _sniff_doc(p), "kind": "reflect", }) fe = _ROOT / "frontends" if fe.is_dir(): for p in sorted(fe.glob("*.py")): if "app" not in p.name or p.name in _HUB_EXCLUDES: continue rel = "frontends/" + p.name if "stapp" in p.name: cmd = [sys.executable, "-m", "streamlit", "run", rel, "--server.headless=true"] else: cmd = [sys.executable, rel] out.append({"name": rel, "cmd": cmd, "doc": _sniff_doc(p), "kind": "frontend"}) return out def start_service(name: str) -> tuple[bool, str]: """Launch a service from list_launchable_services(), detached & window-less (CONSTITUTION rule 14: creationflags at the launch layer only, never via subprocess.Popen monkeypatch). `name` accepts the hub-style path ('reflect/foo.py') or a bare reflect stem ('foo') for backward-compat with `/scheduler start `. """ svcs = list_launchable_services() svc = next((s for s in svcs if s["name"] == name), None) if svc is None: # bare reflect stem fallback cand = "reflect/" + name + ".py" svc = next((s for s in svcs if s["name"] == cand), None) if svc is None: return False, f"未知服务: {name}" try: flags = 0 if os.name == "nt": flags = 0x00000200 | 0x08000000 # NEW_PROCESS_GROUP | NO_WINDOW proc = subprocess.Popen( svc["cmd"], cwd=str(_ROOT), creationflags=flags, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, ) # Poll-and-confirm: if the child dies immediately (bad path, import # error, port-in-use, etc) Popen still returns happily — without this # check the picker would tick "✅ started" while nothing is running, # which is exactly the bug#4 the user hit. 0.4s is the smallest # window that catches "explodes at import" without making the UI # feel laggy on healthy starts. time.sleep(0.4) rc = proc.poll() if rc is not None: return False, f"启动失败 (退出码 {rc}): {svc['name']}" invalidate_running_cache() return True, f"已启动 {svc['name']} (pid={proc.pid})" except Exception as e: return False, f"启动失败: {type(e).__name__}: {e}" # ----- running-state introspection (bug#4) -------------------------------- # Why psutil cmdline-scan instead of a launched-by-us pid registry? # • Services launched by a previous TUI run, or by hub.pyw, must also be # recognised — otherwise /scheduler would happily start a duplicate. # • A registry tied to this process dies when the TUI restarts, but the # services keep running (CREATE_NEW_PROCESS_GROUP). Cmdline scan is the # only single source of truth across launchers, surviving restarts. # Trade-off: it costs ~30ms per /scheduler open, and matches by cmdline tail, # so two checkouts of GA can collide. We accept that — running two GAs out # of two clones is already an unsupported configuration. def _match_service(cmdline: list[str], svc: dict) -> bool: """Does this OS process belong to `svc`? Match on the trailing script arg (`reflect/foo.py` for reflect tasks, `frontends/bar.py` for apps), which is invariant across `python` vs `pythonw` vs venv shims. Reflect detection used to require BOTH `agentmain.py` AND the reflect path in cmdline. That rejected tasks launched directly (`python reflect/scheduler.py`) by launch.pyw, dev scripts, or by an earlier TUI run that used a different launcher — they showed unticked in /scheduler even when alive. Path-only match handles both styles; the Python-process pre-filter in `running_services` keeps false positives (greps, editors with the file open) from sneaking in.""" if not cmdline: return False rel = svc["name"] # 'reflect/foo.py' | 'frontends/bar.py' rel_norm = rel.replace("/", os.sep) return any(rel_norm in (a or "") or rel in (a or "") for a in cmdline) # 2s TTL cache + name-prefilter: ~2.1s → ~1.0s cold, ~0ms warm. # cmdline() is the per-proc cost; only pay it for python-ish survivors. _RUNNING_CACHE: tuple[float, dict[str, int]] | None = None _RUNNING_TTL = 2.0 def invalidate_running_cache() -> None: """Drop the snapshot. Call after start/stop so the next read is fresh.""" global _RUNNING_CACHE _RUNNING_CACHE = None def running_services(use_cache: bool = True) -> dict[str, int]: """{service_name: pid} for live services. {} if psutil missing.""" global _RUNNING_CACHE if use_cache and _RUNNING_CACHE and time.time() - _RUNNING_CACHE[0] < _RUNNING_TTL: return dict(_RUNNING_CACHE[1]) try: import psutil # type: ignore except Exception: return {} svcs = list_launchable_services() out: dict[str, int] = {} me = os.getpid() for proc in psutil.process_iter(["pid", "name"]): try: if proc.info["pid"] == me: continue nm = (proc.info.get("name") or "").lower() if "python" not in nm and "py.exe" not in nm: continue cmd = proc.cmdline() except Exception: continue for svc in svcs: if svc["name"] not in out and _match_service(cmd, svc): out[svc["name"]] = proc.info["pid"] break _RUNNING_CACHE = (time.time(), dict(out)) return out def stop_service(name: str) -> tuple[bool, str]: """Terminate the service `name` if running. Returns (ok, message). Sends SIGTERM-equivalent (Popen.terminate on Windows = TerminateProcess), waits up to 3s, then escalates to kill. Also reaps obvious children (e.g. `python -m streamlit` spawns the actual streamlit worker) so we don't leave orphans behind. """ try: import psutil # type: ignore except Exception: return False, "未安装 psutil,无法停止服务" running = running_services() pid = running.get(name) if pid is None: return False, f"{name} 未在运行" try: parent = psutil.Process(pid) kids = parent.children(recursive=True) for p in [parent, *kids]: try: p.terminate() except Exception: pass gone, alive = psutil.wait_procs([parent, *kids], timeout=3.0) for p in alive: try: p.kill() except Exception: pass invalidate_running_cache() return True, f"已停止 {name} (pid={pid})" except psutil.NoSuchProcess: invalidate_running_cache() return True, f"{name} 已退出" except Exception as e: return False, f"停止失败: {type(e).__name__}: {e}" def list_scheduler_tasks() -> list[dict]: """Return [{name, path, schedule, enabled}] for every sche_tasks/*.json. Used by the /scheduler picker so users can also toggle traditional cron tasks, not just reflect.* scripts. """ out: list[dict] = [] sd = _ROOT / "sche_tasks" if not sd.is_dir(): return out for p in sorted(sd.glob("*.json")): try: data = json.loads(p.read_text(encoding="utf-8")) except Exception: data = {} out.append({ "name": p.stem, "path": str(p), "schedule": data.get("schedule") or data.get("cron") or data.get("every") or "", "enabled": bool(data.get("enabled", True)), }) return out def start_reflect_task(name: str) -> tuple[bool, str]: """Spawn `python reflect/.py` detached. Returns (ok, message). Detached because reflect tasks are long-running; we don't want them to die with the TUI. On Windows we use CREATE_NEW_PROCESS_GROUP|CREATE_NO_WINDOW so no console pops up (per CONSTITUTION rule 14: only at launch layer, no monkeypatching subprocess.Popen). """ script = _ROOT / "reflect" / f"{name}.py" if not script.is_file(): return False, f"reflect/{name}.py 不存在" try: flags = 0 if os.name == "nt": flags = 0x00000200 | 0x08000000 # NEW_PROCESS_GROUP | NO_WINDOW subprocess.Popen( [sys.executable, str(script)], cwd=str(_ROOT), creationflags=flags, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, ) return True, f"已启动 reflect/{name}.py" except Exception as e: return False, f"启动失败: {type(e).__name__}: {e}" # ----- dispatch table for the TUI to register against --------------------- # (cmd, arg_hint, desc) — kept identical between v2 and v3 so the palette # stays consistent across frontends. PALETTE_ENTRIES: list[tuple[str, str, str]] = [ ("/update", "[note]", "git pull 更新 GA 仓库并报告影响面"), ("/autorun", "[seed]", "进入 autonomous_operation 自主模式"), ("/morphling", "[target]", "启用 Morphling 蒸馏 / 吞噬外部技能"), ("/goal", "[goal]", "进入 Goal 模式(需 condition 约束)"), ("/hive", "[target]", "进入 Hive 多 worker 协作模式"), ("/conductor", "[task]", "调用 frontends/conductor.py 多 subagent 编排"), ("/scheduler", "", "多选启动/停止 reflect 任务(cron 由 reflect/scheduler.py 驱动)"), ("/resume", "", "列出最近会话并恢复其中一个(GA 端展开 prompt)"), ] def prompt_for(cmd: str, args_text: str) -> Optional[str]: """Return the injected user-message for a given slash command, or None if the command isn't one of ours (e.g. /scheduler — handled by TUI directly). Language is resolved inside the builders that care about it (see `_current_lang()`); callers never thread it through, so both TUIs keep a single uniform call site. """ table = { "/update": build_update_prompt, "/autorun": build_autorun_prompt, "/morphling": build_morphling_prompt, "/goal": build_goal_prompt, "/hive": build_hive_prompt, "/conductor": build_conductor_prompt, } fn = table.get(cmd) return fn(args_text) if fn else None