chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user