Files
GenericAgent/frontends/slash_cmds.py
T

615 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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. Run `git fetch upstream`; identify the current branch and whether there are "
"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. Align the COMMIT HISTORY to upstream (upstream wins):\n"
" - If local commits exist: `git merge upstream/main` into the current branch, "
"resolving any conflicts in favor of upstream; keep the local commits (a merge commit is fine).\n"
" - Otherwise: `git reset --mixed upstream/main` so the history of local `main` "
"matches upstream/main exactly. Do NOT create any new commit.\n"
"4. IMPORTANT: also reconcile the WORKING TREE; step 3 alone is not enough. "
"`git reset --mixed` and `git merge` only move history and the index. They do not "
"rewrite files that already hold uncommitted local edits, so those stale files keep "
"shadowing upstream's latest. That is why 'HEAD is aligned but the files are still "
"old'. Reconcile per file:\n"
" a. `git diff --name-only upstream/main` lists every tracked file whose working-tree "
"content differs from upstream.\n"
" b. Decide upstream-first per file. A stale leftover, or a local edit that reverts "
"an upstream feature to an older form, takes upstream: back it up, then "
"`git checkout upstream/main -- <file>`. A genuine local enhancement upstream lacks "
"(local config, key-template placeholders, fork-only features) stays, re-applied on "
"top of upstream's latest instead of overwriting it.\n"
" c. Upstream wins; keep local only where it adds value upstream lacks. Never "
"`git add -A`, never blanket whole-branch checkout, never blindly preserve everything.\n"
"5. Do not create any git commit. Back up any file before overwriting it.\n"
"6. Finish with a short summary: branch HEAD, distance vs upstream, per-file outcome "
"(took upstream / kept+adapted local), and where backups went.\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. 先 `git fetch upstream`;识别当前分支,以及是否存在领先 upstream 的本地 commit。\n"
"2. 给出简洁预览:本地尚未包含的 upstream 最近提交(短 hash + 标题 + 日期)及变更文件摘要。\n"
"3. 先对齐【提交历史】到上游(优先上游):\n"
" - 若存在本地 commit:`git merge upstream/main` 合并进当前分支,冲突一律优先上游,保留本地 commit(可产生 merge commit)。\n"
" - 否则:执行 `git reset --mixed upstream/main`,使本地 `main` 的提交历史与 upstream/main 完全对齐。不要创建任何新 commit。\n"
"4. 重要:还须核对【工作区文件】,止步于第 3 步并不够。`git reset --mixed` 与 `git merge` "
"只移动提交历史与索引,不会重写那些已带未提交本地改动的文件,于是这些过时文件继续覆盖在上游"
"最新版之上。这正是“HEAD 已对齐,但文件其实还是旧的”的根因。逐文件核对:\n"
" a. `git diff --name-only upstream/main` 列出工作区内容与上游不一致的全部 tracked 文件。\n"
" b. 每个文件按优先上游判断。过时残留、或把上游特性改回旧版的本地改动,取上游:先备份,再 "
"`git checkout upstream/main -- <file>`。上游没有且仍有效的本地增强(本机配置、密钥模板占位、"
"fork 专属功能)保留,并在上游最新版上重新适配,而非整文件覆盖上游。\n"
" c. 优先上游;本地仅保留上游缺失且有价值的部分。禁止 `git add -A`、禁止整分支 checkout 覆盖、禁止盲目全保留。\n"
"5. 不要进行任何 git commit。覆盖任何文件前先备份。\n"
"6. 最后给出简短小结:分支 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