refactor: archive GUI clients; keep minimal TUI with hooks/API
ci / tcc-check (push) Successful in 1m8s
ci / linux (aarch64, 0) (push) Failing after 1h0m3s
ci / linux (x86, 1) (push) Failing after 1h0m3s
ci / linux (x86_64, 1) (push) Failing after 1h0m2s
ci / windows (push) Failing after 50s

Move Qt/Tauri/full Web SPA under archive/, ship hermit as CLI/TUI plus
libcstcore and cst-webui/1 HTTP+SSE (turn lifecycle hooks, capabilities).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WHC
2026-10-30 22:49:23 +08:00
co-authored by Cursor
parent ea38247161
commit 5538ed3412
184 changed files with 14098 additions and 1858 deletions
+3 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
# Build the Qt Quick Android APK of app/ (the engine is linked in-process, so
# ARCHIVED: Qt APK front end under archive/qt-app; not part of default Hermit builds.
# Build the Qt Quick Android APK of archive/qt-app/ (the engine is linked in-process, so
# the APK opens no port). This is the front end counterpart of
# scripts/build_android.sh, which cross-builds the engine alone.
#
@@ -150,7 +151,7 @@ cmake --build "$BUILD" --target apk -j"$JOBS"
# AGP names the file after the variant and signing state
# (android-build-release.apk vs ...-unsigned.apk), so take the newest one.
APK="$(find "$BUILD/app/android-build/build/outputs/apk" "$BUILD" -name '*.apk' \
APK="$(find "$BUILD/archive/qt-app/android-build/build/outputs/apk" "$BUILD" -name '*.apk' \
-printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)"
[[ -n "$APK" && -f "$APK" ]] || { echo "no APK produced" >&2; exit 1; }
echo
+25 -9
View File
@@ -11,8 +11,13 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
REPLAY="${1:-$ROOT/tests/fixtures/sse/deepseek-native-eos}"
PROTO="${2:-native-eos}"
BIN="${CST_AGENT:-$ROOT/cst-agent}"
[ -x "$BIN" ] || { echo "build cst-agent first ($BIN)"; exit 1; }
BIN="${CST_AGENT:-}"
if [ -z "$BIN" ]; then
if [ -x "$ROOT/hermit" ]; then BIN="$ROOT/hermit"
elif [ -x "$ROOT/cst-agent" ]; then BIN="$ROOT/cst-agent"
fi
fi
[ -x "$BIN" ] || { echo "build hermit first ($ROOT/hermit)"; exit 1; }
WORK="$(mktemp -d "${TMPDIR:-/tmp}/cst-webui-XXXXXX")" 2>/dev/null || {
WORK="$ROOT/.build-tmp/e2e-webui"; rm -rf "$WORK"; mkdir -p "$WORK"; }
@@ -30,6 +35,7 @@ echo "== start cst-agent --web (replay $REPLAY)"
CST_REPLAY_SSE="$REPLAY" \
CST_REPLAY_CHUNK=512 \
"$BIN" --web --web-port 0 --workspace "$WORK" --new --api-key sk-e2e \
--provider deepseek --model deepseek-chat \
--protocol "$PROTO" --tools default --yolo > "$LOG" 2>&1 &
SRV_PID=$!
@@ -61,10 +67,11 @@ stop_server() {
[ "$st" -lt 128 ] || fail "$2 died with status $st on SIGTERM"
}
# 1. the shell page and the state endpoint
curl -fsS --max-time 5 "$BASE/" | grep -q "<title>CST-Agent" || fail "GET / is not the shell page"
# 1. the health page and the state endpoint
curl -fsS --max-time 5 "$BASE/" | grep -q "<title>Hermit API" || fail "GET / is not the health page"
curl -fsS --max-time 5 "$BASE/api/state" > "$WORK/state.json" || fail "GET /api/state"
grep -q '"protocol":"cst-webui/1"' "$WORK/state.json" || fail "state has no protocol tag"
grep -q '"capabilities"' "$WORK/state.json" || fail "state has no capabilities"
grep -q '"session":' "$WORK/state.json" || fail "state has no session id"
# 2. an unknown route is a 404, an oversized body a 413
@@ -92,6 +99,7 @@ while [ $i -lt 450 ]; do
done
kill "$CURL_PID" 2>/dev/null || true
grep -q "event: turn_end" "$EVENTS" || fail "the turn never finished"
grep -q "event: turn_begin" "$EVENTS" || fail "no turn_begin event"
grep -q "event: assistant_html" "$EVENTS" || fail "no assistant markdown was streamed"
grep -q "event: tool" "$EVENTS" || fail "no tool event was streamed"
grep -q '"rc":0' "$EVENTS" || fail "the turn did not end with rc=0"
@@ -180,6 +188,7 @@ P_LOG="$WORK/permission-server.log" # its own log: the port parse must not
# pick up the first server's URL
CST_REPLAY_SSE="$REPLAY" CST_REPLAY_CHUNK=512 \
"$BIN" --web --web-port 0 --workspace "$WORK/pw" --new --api-key sk-e2e \
--provider deepseek --model deepseek-chat \
--protocol "$PROTO" --tools default --approval ask --confirm all \
> "$P_LOG" 2>&1 &
P_PID=$!
@@ -229,6 +238,7 @@ echo "== token auth and --web-dev"
T_LOG="$WORK/token-server.log"
CST_REPLAY_SSE="$REPLAY" \
"$BIN" --web --web-port 0 --workspace "$WORK/pw" --new --api-key sk-e2e \
--provider deepseek --model deepseek-chat \
--protocol "$PROTO" --tools default --yolo --web-dev "$ROOT/web" \
--web-token tok-e2e > "$T_LOG" 2>&1 &
T_PID=$!
@@ -257,11 +267,17 @@ code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
-H 'Authorization: Bearer tok-e2' "$T_BASE/api/state")"
[ "$code" = "401" ] || fail "a wrong token was accepted ($code)"
# --web-dev serves straight from the directory, so the bytes must match it
curl -fsS --max-time 5 -H 'Authorization: Bearer tok-e2e' "$T_BASE/app.js" \
> "$WORK/app.js" || fail "GET /app.js from --web-dev"
cmp -s "$WORK/app.js" "$ROOT/web/app.js" ||
fail "--web-dev did not serve web/app.js from disk"
# --web-dev serves the health page from disk
curl -fsS --max-time 5 -H 'Authorization: Bearer tok-e2e' "$T_BASE/" \
> "$WORK/index.html" || fail "GET / from --web-dev"
cmp -s "$WORK/index.html" "$ROOT/web/index.html" ||
fail "--web-dev did not serve web/index.html from disk"
curl -fsS --max-time 5 -H 'Authorization: Bearer tok-e2e' "$T_BASE/api/state" \
> "$WORK/state.json" || fail "GET /api/state with token"
grep -q '"capabilities"' "$WORK/state.json" ||
fail "/api/state missing capabilities"
grep -q 'turn_begin' "$WORK/state.json" ||
fail "/api/state capabilities missing turn_begin"
# POST /api/quit must return and then take the server down on its own
curl -fsS --max-time 5 -X POST -H 'Authorization: Bearer tok-e2e' \
+8 -5
View File
@@ -113,11 +113,14 @@ def port():
def settings():
try:
with open(home + "/.config/cst-agent/settings.json", encoding="utf-8") as f:
return json.load(f)
except OSError:
return {}
for name in ("hermit", "cst-agent"):
path = home + "/.config/%s/settings.json" % name
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except OSError:
continue
return {}
def busy(base):
+8 -4
View File
@@ -141,8 +141,10 @@ try:
check(status(r)[:1] == [200], "a client without Origin (curl) may POST")
r = raw(PORT, req("GET", "/api/attach/img-0000.png", host="evil.example"))
check(status(r)[:1] == [401], "session images need the Host check too (%s)" % status(r))
r = raw(PORT, req("GET", "/app.js"))
check(status(r)[:1] == [200], "assets are served")
r = raw(PORT, req("GET", "/"))
check(status(r)[:1] == [200], "health page is served")
r = raw(PORT, req("GET", "/index.html"))
check(status(r)[:1] == [200], "embedded index.html is served")
# 2. request parsing
r = raw(PORT, req("GET", "/api/state%00x"))
check(status(r)[:1] == [400], "an encoded NUL in the path is a 400 (%s)" % status(r))
@@ -206,8 +208,10 @@ try:
ws2 = os.path.join(root, "b")
os.makedirs(ws2)
p, PORT, log = start(ws2, "--web-token", "tok-x")
r = raw(PORT, req("GET", "/app.js"))
check(status(r)[:1] == [200], "token mode: app.js loads without the token (%s)" % status(r))
r = raw(PORT, req("GET", "/"))
check(status(r)[:1] == [200], "token mode: health page loads without the token (%s)" % status(r))
r = raw(PORT, req("GET", "/index.html"))
check(status(r)[:1] == [200], "token mode: index.html loads without the token (%s)" % status(r))
r = raw(PORT, req("GET", "/api/state"))
check(status(r)[:1] == [401], "token mode: the API needs it")
r = raw(PORT, req("GET", "/api/attach/img-0000.png"))
+16 -2
View File
@@ -166,9 +166,23 @@ def messages(base):
return call(base, "/api/session")[1].get("messages", [])
def _is_prompt(m):
"""Real user prompts only — skip notes, tool results, and engine meta rows."""
if m.get("role") != "user" or m.get("note") or m.get("tool_result"):
return False
c = m.get("content")
if not isinstance(c, str) or not c:
return False
kind = (m.get("meta") or {}).get("kind") if isinstance(m.get("meta"), dict) else None
if kind:
return False
if c.startswith("[cst"):
return False
return True
def prompts(base):
return [m["content"] for m in messages(base)
if m["role"] == "user" and not m.get("note") and not m.get("tool_result")]
return [m["content"] for m in messages(base) if _is_prompt(m)]
def answers(base):
+2 -2
View File
@@ -151,8 +151,8 @@ def serve(handler):
api_a, api_b = serve(A), serve(B)
ws = tempfile.mkdtemp(prefix="cst-set-")
home = tempfile.mkdtemp(prefix="cst-home-")
cfgdir = home + "/.config/cst-agent"
os.makedirs(cfgdir)
cfgdir = home + "/.config/hermit"
os.makedirs(cfgdir, exist_ok=True)
with open(cfgdir + "/models.json", "w") as f:
json.dump({"providers": {"fakeb": {"api_base": "http://127.0.0.1:%d/v1" % api_b.server_address[1],
"key_env": "FAKEB_API_KEY", "default_model": ""}},
+3 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
# Build and pack the Windows GUI (app/cst-agent-app.exe) from Linux.
# ARCHIVED: Qt GUI lives under archive/qt-app; not part of default Hermit builds.
# Build and pack the Windows GUI (archive/qt-app/cst-agent-app.exe) from Linux.
#
# scripts/package_windows_app.sh (run it from the repo root)
# nix-shell shell-win.nix --run scripts/package_windows_app.sh
@@ -161,7 +162,7 @@ cmake -S "$ROOT" -B "$BUILD" -G Ninja \
# POSIX (tests/test_tools_ext.c calls mkdir with two arguments), exactly as in
# the TUI cross build.
cmake --build "$BUILD" -j"$(nproc)" --target cst-agent-app
EXE="$BUILD/app/cst-agent-app.exe"
EXE="$BUILD/archive/qt-app/cst-agent-app.exe"
[[ -f "$EXE" ]] || { echo "no cst-agent-app.exe" >&2; exit 1; }
# --- the package -----------------------------------------------------------