Root cause of the lingering Ctrl+S freeze even when the session is
idle: `InputArea.reset()` calls `self.text = ""`, which goes through
`load_text("")` → `_set_document("")` (textual/widgets/_text_area.py
:1181-1186). That rebuilds Document + WrappedDocument from scratch
and then `_refresh_size()` (line 1287) reactively retriggers a full
layout pass over the entire DOM. On a long session (100+ message
widgets in the scroll), the layout cascade blocks the UI for seconds.
Switch to `TextArea.clear()` which goes through the edit pipeline:
`delete((0,0), document.end)` → `wrapped_document.wrap_range(...)` —
no Document rebuild, range-scoped wrap only. Guarded with
`if self.document.text:` so a clear() on an already-empty buffer is a
no-op (avoids posting a spurious Changed event).
Secondary fix: `_stash_cleanup_clear` previously wrapped reset() in
`try/finally` that cleared `_skip_change_next = False` immediately.
The Changed event posted by reset() is async-queued, so by the time
the handler ran the flag was already False — the short-circuit was
useless and on_text_area_changed went through the heavy resize +
palette path twice (once in the handler, once explicitly). Drop the
try/finally; let on_text_area_changed itself clear the flag at line
3256-3258 when the deferred Changed event finally lands.
Also scrubs external TUI-project names from comments per request.
589 lines
24 KiB
Python
589 lines
24 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
|
||
|
||
|
||
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. Fetch upstream; identify the current branch and whether there are any local "
|
||
"commits ahead of upstream.\n"
|
||
"2. Show a concise preview: recent upstream commits not yet local "
|
||
"(short hash + subject + date) plus a changed-files summary.\n"
|
||
"3. Apply the update (uncommitted working-tree changes must always be preserved):\n"
|
||
" - If local commits exist: merge upstream into the current branch, auto-resolving "
|
||
"conflicts in favor of upstream, and keep the local commits. A merge commit is acceptable here.\n"
|
||
" - Otherwise: run `git reset --mixed upstream/main` so local `main` aligns exactly "
|
||
"with upstream/main, automatically resolving conflicts in favor of upstream. Keep the local changes but do not create any new commits.\n"
|
||
"4. Finish with a short summary: branch HEAD, changed files, conflicts/resolutions.\n"
|
||
"\n"
|
||
"#### Say 'Congratulations! 🎉' to the user\n"
|
||
"After a successful update, say: \"Congratulations! 🎉 You have successfully "
|
||
"updated GenericAgent!\"\n"
|
||
"\n"
|
||
"#### Ask for a Star ⭐\n"
|
||
"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. 先 fetch upstream;识别当前分支,以及是否存在领先 upstream 的本地 commit。\n"
|
||
"2. 给出简洁预览:本地尚未包含的 upstream 最近提交(短 hash + 标题 + 日期)及变更文件摘要。\n"
|
||
"3. 执行更新(工作区未提交的改动始终要保留):\n"
|
||
" - 若存在本地 commit:将 upstream 合并进当前分支,自动解决冲突且优先上游。\n"
|
||
" - 否则:执行 `git reset --mixed upstream/main`,使本地 `main` 与 upstream/main 完全对齐,自动解决冲突且优先上游。本地修改保留但不要进行commit。\n"
|
||
"4. 最后给出简短小结:分支 HEAD、变更文件、冲突/解决方式。\n"
|
||
"\n"
|
||
"#### 向用户说 'Congratulations! 🎉'\n"
|
||
"更新成功后,请对用户说:\"Congratulations! 🎉 你已成功更新 GenericAgent!\"\n"
|
||
"\n"
|
||
"#### 邀请用户点 Star ⭐\n"
|
||
"随后可邀请:\"如果觉得有帮助,要不要给 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
|