598 lines
25 KiB
Python
598 lines
25 KiB
Python
"""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 <bot@gaagent.ai>" 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 -- <file>`.\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 -- <file>`。\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 <task>` → 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/<f>]
|
||
• frontends/*app*.py (not excluded)
|
||
→ 'stapp' → `python -m streamlit run … --server.headless=true`
|
||
others → `python frontends/<f>`
|
||
|
||
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 <stem>`.
|
||
"""
|
||
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/<name>.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
|