Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea38247161 | ||
|
|
15c6c4a02a | ||
|
|
f29c1000b6 | ||
|
|
41f4cf85ee | ||
|
|
8fcbd886c3 | ||
|
|
f30b4ede48 | ||
|
|
2602806aea | ||
|
|
814a31c9c8 | ||
|
|
f00aad9e3e | ||
|
|
5917c48bd0 | ||
|
|
2ccad623e4 | ||
|
|
67b9d6683f | ||
|
|
6d2e106e26 | ||
|
|
1b0f14533a | ||
|
|
7d183d5bba | ||
|
|
6e09f79666 | ||
|
|
00288108cd | ||
|
|
c329db504d | ||
|
|
de2eaf87f7 | ||
|
|
2f96b050d6 | ||
|
|
812e3ee265 | ||
|
|
1605d74f17 | ||
|
|
5900b53642 | ||
|
|
a3e115539e | ||
|
|
e610d70c30 | ||
|
|
9c6257fc96 | ||
|
|
be8d021480 | ||
|
|
e853abac5e | ||
|
|
de9ee28773 | ||
|
|
08ca613a0e | ||
|
|
5ee82f126d | ||
|
|
dade4072cf | ||
|
|
acd6f1d998 | ||
|
|
d971cb02b0 | ||
|
|
143dba203b |
@@ -0,0 +1,22 @@
|
||||
.git
|
||||
result
|
||||
build/
|
||||
build-*/
|
||||
dist/
|
||||
third_party/
|
||||
backups/
|
||||
reports/
|
||||
**/*.o
|
||||
**/*.a
|
||||
**/cst-agent
|
||||
tests/test_*
|
||||
!tests/test_*.c
|
||||
app/
|
||||
.cursor/
|
||||
*.apk
|
||||
*.tar.gz
|
||||
.gen/
|
||||
vendor/sashidown/*.o
|
||||
vendor/sashidown/*.a
|
||||
vendor/cjson/*.o
|
||||
vendor/monocypher/*.o
|
||||
@@ -0,0 +1,183 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Verification: TinyCC builds and tests everything in seconds. tcc warns on
|
||||
# `#pragma GCC`, so no -Werror here; the gcc release builds below carry it.
|
||||
tcc-check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
|
||||
-o /tmp/ci_checkout.sh
|
||||
bash /tmp/ci_checkout.sh
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
SUDO=$(command -v sudo || true)
|
||||
$SUDO bash scripts/ci_pkg_mirror.sh
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config python3 curl ca-certificates \
|
||||
libcurl4-openssl-dev libncurses-dev
|
||||
- name: Build TinyCC
|
||||
run: bash scripts/ci_tcc.sh
|
||||
- name: Build
|
||||
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" -j"$(nproc)" hermit
|
||||
- name: Unit tests
|
||||
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" check
|
||||
- name: Smoke test
|
||||
run: |
|
||||
./hermit --help
|
||||
./hermit --workspace "$PWD" --list-sessions
|
||||
./hermit --demo-ctl --workspace .
|
||||
- name: WebUI end-to-end (recorded stream, no API key)
|
||||
run: sh scripts/e2e_webui.sh
|
||||
|
||||
# Release: static musl binaries, gcc with -Werror.
|
||||
# dist/cst-agent-VERSION-linux-ARCH.tar.gz
|
||||
linux:
|
||||
needs: tcc-check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x86_64
|
||||
check: 1
|
||||
- arch: x86
|
||||
check: 1
|
||||
- arch: aarch64
|
||||
check: 0
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
|
||||
-o /tmp/ci_checkout.sh
|
||||
bash /tmp/ci_checkout.sh
|
||||
- name: Install host tools
|
||||
run: |
|
||||
SUDO=$(command -v sudo || true)
|
||||
$SUDO bash scripts/ci_pkg_mirror.sh
|
||||
$SUDO apt-get update
|
||||
# ncurses-bin/-term: tic, infocmp and the fallback terminal entries
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
build-essential cmake ninja-build pkg-config python3 perl \
|
||||
curl ca-certificates xz-utils bzip2 file ncurses-bin ncurses-term
|
||||
if [ "${{ matrix.arch }}" = aarch64 ]; then
|
||||
$SUDO apt-get install -y --no-install-recommends qemu-user-static
|
||||
fi
|
||||
- name: Cache musl toolchain + third_party prefix
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
${{ env.HOME }}/.local/toolchains
|
||||
/opt/*musl*
|
||||
third_party/prefix-linux-${{ matrix.arch }}
|
||||
third_party/src
|
||||
key: linux-${{ matrix.arch }}-tp-v1-${{ hashFiles('scripts/build_third_party_linux.sh', 'scripts/ci_linux_toolchain.sh') }}
|
||||
restore-keys: |
|
||||
linux-${{ matrix.arch }}-tp-v1-
|
||||
- name: Build (static, -Werror)
|
||||
env:
|
||||
CST_LINUX_CHECK: ${{ matrix.check }}
|
||||
run: bash scripts/package_linux.sh ${{ matrix.arch }}
|
||||
- name: Smoke test
|
||||
run: |
|
||||
d=$(ls -d dist/hermit-*-linux-${{ matrix.arch }})
|
||||
file "$d/hermit"
|
||||
run=""
|
||||
[ "${{ matrix.arch }}" = aarch64 ] && run=qemu-aarch64-static
|
||||
$run "$d/hermit" --version
|
||||
$run "$d/hermit" --help >/dev/null
|
||||
$run "$d/hermit" --workspace "$PWD" --list-sessions
|
||||
cat dist/hermit-*-linux-${{ matrix.arch }}.tar.gz.sha256
|
||||
# Release vVERSION (from include/cst_version.h) on Gitea. Prefer repo
|
||||
# secret RELEASE_TOKEN (write:repository); fall back to the job token.
|
||||
- name: Publish Gitea Release
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CST_GITEA: ${{ github.server_url }}
|
||||
CST_REPO: ${{ github.repository }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
run: bash scripts/ci_publish.sh dist/hermit-*-linux-${{ matrix.arch }}.tar.gz dist/cst-agent-*-linux-${{ matrix.arch }}.tar.gz
|
||||
|
||||
# Release: mingw-w64 UCRT gcc (Debian trixie) with -Werror.
|
||||
# dist/cst-agent-VERSION-windows-x86_64.zip and -setup.exe (NSIS)
|
||||
windows:
|
||||
needs: tcc-check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
container: debian:trixie
|
||||
steps:
|
||||
- name: Install tools
|
||||
run: |
|
||||
# No CA certificates in the image yet, so the mirror is plain http.
|
||||
sed -i 's#https\?://deb.debian.org#http://mirrors.tuna.tsinghua.edu.cn#g' \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl git build-essential cmake ninja-build make \
|
||||
pkg-config python3 perl zip unzip xz-utils patch file \
|
||||
gcc-mingw-w64-ucrt64 binutils-mingw-w64-ucrt64 nsis
|
||||
- name: Checkout
|
||||
env:
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
|
||||
-o /tmp/ci_checkout.sh
|
||||
bash /tmp/ci_checkout.sh
|
||||
- name: Cache mingw third_party
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
third_party/prefix-mingw-x86_64
|
||||
third_party/src
|
||||
third_party/busybox
|
||||
key: win-tp-v1-${{ hashFiles('scripts/build_third_party_mingw.sh', 'scripts/bootstrap_windows.sh') }}
|
||||
restore-keys: |
|
||||
win-tp-v1-
|
||||
- name: Cross-build zip and installer
|
||||
env:
|
||||
CST_WIN_CFLAGS: -Wall -Wextra -Werror
|
||||
run: bash scripts/ci_windows_cross.sh
|
||||
- name: Verify
|
||||
run: |
|
||||
ls -lah dist/windows-x86_64/
|
||||
file dist/windows-x86_64/cst-agent.exe dist/cst-agent-*-windows-x86_64-setup.exe
|
||||
cat dist/cst-agent-*-windows-x86_64.zip.sha256 dist/cst-agent-*-windows-x86_64-setup.exe.sha256
|
||||
unzip -l dist/cst-agent-*-windows-x86_64.zip
|
||||
- name: Publish Gitea Release
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CST_GITEA: ${{ github.server_url }}
|
||||
CST_REPO: ${{ github.repository }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
run: |
|
||||
bash scripts/ci_publish.sh dist/cst-agent-*-windows-x86_64.zip \
|
||||
dist/cst-agent-*-windows-x86_64-setup.exe
|
||||
@@ -1,6 +1,8 @@
|
||||
*.o
|
||||
*.d
|
||||
cst-agent
|
||||
cst-agent.exe
|
||||
libcstcore.a
|
||||
tests/test_*
|
||||
!tests/test_*.c
|
||||
session.jsonl
|
||||
@@ -56,3 +58,11 @@ libcstcore.a
|
||||
|
||||
# nix-build
|
||||
result
|
||||
hermit
|
||||
.peer_probe.out
|
||||
reports/
|
||||
|
||||
# Tauri desktop
|
||||
apps/desktop/node_modules/
|
||||
apps/desktop/dist/
|
||||
apps/desktop/src-tauri/target/
|
||||
|
||||
@@ -105,6 +105,8 @@ set(CJSON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/vendor/cjson")
|
||||
|
||||
set(CST_CORE_SOURCES
|
||||
vendor/cjson/cJSON.c
|
||||
vendor/monocypher/monocypher.c
|
||||
vendor/monocypher/monocypher-ed25519.c
|
||||
src/util.c
|
||||
src/ctl_parse.c
|
||||
src/os_compat.c
|
||||
@@ -117,6 +119,14 @@ set(CST_CORE_SOURCES
|
||||
src/plugins.c
|
||||
src/mcp.c
|
||||
src/subagent.c
|
||||
src/runtime.c
|
||||
src/worktree.c
|
||||
src/realm.c
|
||||
src/discover.c
|
||||
src/peer_auth.c
|
||||
src/parliament.c
|
||||
src/parliament_daemon.c
|
||||
src/parliament_cli.c
|
||||
src/attach.c
|
||||
src/clipboard.c
|
||||
src/pdf_text.c
|
||||
@@ -142,6 +152,7 @@ set(CST_CORE_SOURCES
|
||||
src/models.c
|
||||
src/model_registry.c
|
||||
src/metrics.c
|
||||
src/hermit_glyph.c
|
||||
src/net.c
|
||||
src/http_srv.c
|
||||
src/web_md.c
|
||||
@@ -216,7 +227,12 @@ target_include_directories(cstcore PUBLIC
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/include"
|
||||
"${SD_DIR}/include"
|
||||
"${CJSON_DIR}"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/vendor/monocypher"
|
||||
)
|
||||
set_source_files_properties(
|
||||
vendor/monocypher/monocypher.c
|
||||
vendor/monocypher/monocypher-ed25519.c
|
||||
PROPERTIES COMPILE_OPTIONS "-w")
|
||||
target_compile_definitions(cstcore PRIVATE _GNU_SOURCE)
|
||||
if(WIN32)
|
||||
target_compile_definitions(cstcore PRIVATE _WIN32_WINNT=0x0A00)
|
||||
@@ -242,6 +258,7 @@ else()
|
||||
endif()
|
||||
|
||||
add_executable(cst-agent ${CST_SOURCES})
|
||||
set_target_properties(cst-agent PROPERTIES OUTPUT_NAME hermit)
|
||||
add_dependencies(cst-agent sashidown)
|
||||
cst_win_exe(cst-agent)
|
||||
|
||||
@@ -376,7 +393,8 @@ set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode
|
||||
test_shell test_diff test_tools_fs test_tools_ext test_registry
|
||||
test_undo test_config test_models test_model_registry test_sandbox
|
||||
test_keymap test_shell_compact test_skills
|
||||
test_plugins test_mcp test_subagent test_lowil
|
||||
test_plugins test_mcp test_subagent test_runtime test_realm test_discover
|
||||
test_peer_auth test_parliament test_lowil
|
||||
test_win_utf8 test_os_dir test_tmpdir
|
||||
test_tool_meta test_embed)
|
||||
if(CST_BUILD_WEBUI)
|
||||
@@ -384,17 +402,13 @@ if(CST_BUILD_WEBUI)
|
||||
list(APPEND _CST_CORE_TESTS test_webui)
|
||||
endif()
|
||||
foreach(t ${_CST_CORE_TESTS})
|
||||
add_executable(${t} tests/${t}.c ${CST_CORE_SOURCES})
|
||||
# The core now contains src/web_md.c, which needs sashidown's header and
|
||||
# archive exactly like the Makefile build does.
|
||||
# Link the shared cstcore archive instead of recompiling every core .c.
|
||||
add_executable(${t} tests/${t}.c)
|
||||
target_include_directories(${t} PRIVATE include "${CJSON_DIR}" "${SD_DIR}/include")
|
||||
target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
|
||||
target_link_libraries(${t} PRIVATE
|
||||
"${SASHIDOWN_A}" CURL::libcurl Threads::Threads)
|
||||
target_link_libraries(${t} PRIVATE cstcore)
|
||||
add_dependencies(${t} sashidown)
|
||||
if(NOT WIN32)
|
||||
target_link_libraries(${t} PRIVATE m)
|
||||
else()
|
||||
if(WIN32)
|
||||
target_link_libraries(${t} PRIVATE ws2_32 crypt32 secur32 bcrypt advapi32 user32)
|
||||
endif()
|
||||
cst_win_exe(${t})
|
||||
@@ -414,9 +428,22 @@ endif()
|
||||
|
||||
if(WIN32)
|
||||
install(TARGETS cst-agent RUNTIME DESTINATION .)
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-icons.ttf")
|
||||
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-icons.ttf" DESTINATION .)
|
||||
endif()
|
||||
else()
|
||||
include(GNUInstallDirs)
|
||||
install(TARGETS cst-agent RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-icons.ttf")
|
||||
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-icons.ttf"
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/hermit)
|
||||
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-icons.ttf"
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/fonts/hermit)
|
||||
endif()
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-frames")
|
||||
install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-frames/"
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/hermit/frames)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
message(STATUS "Compiler: ${CMAKE_C_COMPILER_ID} ${CMAKE_C_COMPILER}")
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# 从 CST-Agent 迁移到 Hermit
|
||||
|
||||
**本仓库 `WHC/CST-agent` 保留为兼容入口。** 产品名与规范克隆地址已改为:
|
||||
|
||||
**https://git.whcself.xyz/WHC/Hermit**
|
||||
|
||||
二进制、配置与环境变量自 **0.10.0** 起以 Hermit 为准;`CST-agent` 仓库与旧包名仅作过渡。
|
||||
|
||||
---
|
||||
|
||||
## 1. 换源码远程(推荐)
|
||||
|
||||
已有检出:
|
||||
|
||||
```bash
|
||||
# 一键(在仓库根目录)
|
||||
./scripts/migrate_remote_to_hermit.sh
|
||||
|
||||
# 或手动
|
||||
git remote set-url origin https://git.whcself.xyz/WHC/Hermit.git
|
||||
git fetch origin
|
||||
git branch -u origin/main
|
||||
```
|
||||
|
||||
新克隆:
|
||||
|
||||
```bash
|
||||
git clone https://git.whcself.xyz/WHC/Hermit.git
|
||||
cd Hermit
|
||||
make -j$(nproc) && make install PREFIX=$HOME/.local
|
||||
```
|
||||
|
||||
旧地址仍可拉代码一段时间:
|
||||
|
||||
```bash
|
||||
git clone https://git.whcself.xyz/WHC/CST-agent.git # 兼容;请改 origin 到 Hermit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 二进制与 PATH
|
||||
|
||||
| 旧 | 新 |
|
||||
|---|---|
|
||||
| `cst-agent` | `hermit`(安装时仍提供 `cst-agent` → `hermit` 软链) |
|
||||
| `cst-agent-update` | → `hermit-update`(软链;议会助手 → `hermit-service`) |
|
||||
| Release `cst-agent-VERSION-linux-*.tar.gz` | `hermit-VERSION-linux-*.tar.gz`(过渡期可能双发) |
|
||||
|
||||
```bash
|
||||
hermit --version # hermit 1.0.x
|
||||
hermit-update # 升级;兼容名 cst-agent-update
|
||||
cst-agent --version # 同二进制(软链)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 配置目录
|
||||
|
||||
| 旧 | 新 |
|
||||
|---|---|
|
||||
| `~/.config/cst-agent/` | `~/.config/hermit/` |
|
||||
| Windows `%APPDATA%\cst-agent` | `%APPDATA%\hermit` |
|
||||
|
||||
程序若发现 **仅有** 旧目录、尚无新目录,会继续读旧路径。建议主动迁移:
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
mkdir -p ~/.config/hermit
|
||||
# 若尚未迁过:
|
||||
cp -a ~/.config/cst-agent/. ~/.config/hermit/ 2>/dev/null || true
|
||||
```
|
||||
|
||||
工作区 `.agent/` 会话目录**不变**,无需改动。
|
||||
|
||||
---
|
||||
|
||||
## 4. 环境变量
|
||||
|
||||
`HERMIT_*` 优先;未设置时仍读 `CST_*`。
|
||||
|
||||
| 用途 | 新 | 旧(兼容) |
|
||||
|---|---|---|
|
||||
| Provider / Key / Model | `HERMIT_PROVIDER` 等 | `CST_PROVIDER` 等 |
|
||||
| ASCII / Icons | `HERMIT_ASCII` / `HERMIT_ICONS` | `CST_ASCII` / `CST_ICONS` |
|
||||
| 议会密码等 | `HERMIT_REALM_PASSWORD`(若已接双读) | `CST_REALM_PASSWORD` |
|
||||
|
||||
systemd:`EnvironmentFile` 已同时尝试 `~/.config/hermit/parliament.env` 与旧路径。
|
||||
|
||||
---
|
||||
|
||||
## 5. CI / 脚本 / 文档链接
|
||||
|
||||
- 安装脚本、bootstrap、文档中的规范 URL 改为 `…/WHC/Hermit`。
|
||||
- 本文件与 README 顶栏是 **CST-agent 仓库上的最后一次迁移说明**;后续功能请在 **Hermit** 仓库跟进。
|
||||
|
||||
---
|
||||
|
||||
## 6. 核对清单
|
||||
|
||||
- [ ] `git remote -v` 指向 `WHC/Hermit.git`
|
||||
- [ ] `hermit --version` 正常;PATH 上可选保留 `cst-agent` 软链
|
||||
- [ ] `~/.config/hermit/` 有 credentials / settings(或确认仍在用旧目录回退)
|
||||
- [ ] 书签 / 镜像 / CI 的 clone URL 已更新
|
||||
- [ ](可选)归档或只读化 `CST-agent` 仓库说明,避免双主开发
|
||||
@@ -2,6 +2,7 @@ CC ?= gcc
|
||||
CFLAGS ?= -std=c11 -Wall -Wextra -O2 -g
|
||||
SD_DIR := vendor/sashidown
|
||||
CJSON_DIR := vendor/cjson
|
||||
MONO_DIR := vendor/monocypher
|
||||
PKG_CONFIG ?= pkg-config
|
||||
PREFIX ?= /usr/local
|
||||
DESTDIR ?=
|
||||
@@ -9,6 +10,16 @@ UNAME_S := $(shell uname -s 2>/dev/null)
|
||||
UNAME_O := $(shell uname -o 2>/dev/null)
|
||||
comma := ,
|
||||
|
||||
# Optional ccache: USE_CCACHE=1 forces it; USE_CCACHE=0 disables; else auto.
|
||||
USE_CCACHE ?= auto
|
||||
ifeq ($(USE_CCACHE),1)
|
||||
CC := $(shell command -v ccache >/dev/null && echo "ccache $(CC)" || echo "$(CC)")
|
||||
else ifeq ($(USE_CCACHE),auto)
|
||||
ifneq ($(findstring ccache,$(CC)),ccache)
|
||||
CC := $(shell command -v ccache >/dev/null && echo "ccache $(CC)" || echo "$(CC)")
|
||||
endif
|
||||
endif
|
||||
|
||||
# Android (Termux): pthreads live in libc.
|
||||
PTHREAD_LIBS := -lpthread
|
||||
ifeq ($(UNAME_O),Android)
|
||||
@@ -60,6 +71,10 @@ CST_WEBUI ?= 1
|
||||
PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS)
|
||||
PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS)
|
||||
|
||||
# Upstream Monocypher: keep -Werror builds green without patching the vendor.
|
||||
$(MONO_DIR)/monocypher.o $(MONO_DIR)/monocypher-ed25519.o: %.o: %.c
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -w -c -o $@ $<
|
||||
|
||||
# tcc writes no rpath of its own, so shared libs found via pkg-config -L
|
||||
# (Nix store, /opt prefixes) would not load at run time.
|
||||
RPATH_LIBS :=
|
||||
@@ -70,7 +85,8 @@ endif
|
||||
endif
|
||||
|
||||
# Keep POSIX declarations visible for shared test helpers (notably symlink()).
|
||||
CPPFLAGS += -Iinclude -I.gen -I$(SD_DIR)/include -I$(CJSON_DIR) -D_POSIX_C_SOURCE=200809L -D_XOPEN_SOURCE_EXTENDED $(PKG_CFLAGS)
|
||||
CPPFLAGS += -Iinclude -I.gen -I$(SD_DIR)/include -I$(CJSON_DIR) -I$(MONO_DIR) \
|
||||
-D_POSIX_C_SOURCE=200809L -D_XOPEN_SOURCE_EXTENDED $(PKG_CFLAGS)
|
||||
LDFLAGS += -L$(SD_DIR)
|
||||
LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS) $(RPATH_LIBS)
|
||||
|
||||
@@ -87,6 +103,8 @@ endif
|
||||
|
||||
CORE_SRC = \
|
||||
$(CJSON_DIR)/cJSON.c \
|
||||
$(MONO_DIR)/monocypher.c \
|
||||
$(MONO_DIR)/monocypher-ed25519.c \
|
||||
src/util.c \
|
||||
src/ctl_parse.c \
|
||||
src/os_compat.c \
|
||||
@@ -97,7 +115,8 @@ CORE_SRC = \
|
||||
src/skills.c \
|
||||
src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.c \
|
||||
src/pdf_text.c \
|
||||
src/session.c \
|
||||
src/session.c src/runtime.c src/worktree.c src/realm.c src/discover.c \
|
||||
src/peer_auth.c src/parliament.c src/parliament_daemon.c src/parliament_cli.c \
|
||||
src/tools/tools.c \
|
||||
src/tools/patch.c \
|
||||
src/tools/registry.c \
|
||||
@@ -117,6 +136,7 @@ CORE_SRC = \
|
||||
src/mm_tool.c \
|
||||
src/models.c src/model_registry.c \
|
||||
src/metrics.c \
|
||||
src/hermit_glyph.c \
|
||||
$(WEB_SRC)
|
||||
|
||||
SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c
|
||||
@@ -125,8 +145,9 @@ OBJ = $(SRC:.c=.o)
|
||||
# the GUI links the same engine the CLI/TUI/WebUI run. No TUI, no main.
|
||||
CORE_OBJ = $(CORE_SRC:.c=.o)
|
||||
CST_CORE_LIB = libcstcore.a
|
||||
HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h \
|
||||
.gen/models_builtin.inc .gen/web_assets.inc
|
||||
# Generated embeds; per-file .d files (from -MMD) track the rest.
|
||||
GEN_INCS = .gen/models_builtin.inc .gen/web_assets.inc
|
||||
DEPFILES = $(OBJ:.o=.d)
|
||||
|
||||
TESTS = \
|
||||
tests/test_ctl_frontier \
|
||||
@@ -164,6 +185,12 @@ TESTS = \
|
||||
tests/test_plugins \
|
||||
tests/test_mcp \
|
||||
tests/test_subagent \
|
||||
tests/test_runtime \
|
||||
tests/test_prompt_contract \
|
||||
tests/test_realm \
|
||||
tests/test_discover \
|
||||
tests/test_peer_auth \
|
||||
tests/test_parliament \
|
||||
tests/test_lowil \
|
||||
tests/test_win_utf8 \
|
||||
tests/test_os_dir \
|
||||
@@ -178,35 +205,53 @@ TEST_LIBS = -L$(SD_DIR) -lsashidown $(CURL_LIBS) $(RPATH_LIBS) $(TEST_SYS_LIBS)
|
||||
# cst_test_util.h has static os_* helpers; gcc drops them when unused, tcc
|
||||
# keeps them, so the small tests link the OS layer too.
|
||||
TEST_OS_SRC = src/os_compat.c src/os_win.c src/util.c
|
||||
# hermit_getenv is in os_compat.c (HERMIT_/CST_ dual env).
|
||||
|
||||
.PHONY: all clean check demo sashidown-lib FORCE cmake-gcc cmake-clang asan install uninstall
|
||||
.PHONY: all clean check demo sashidown-lib cmake-gcc cmake-clang asan install \
|
||||
uninstall install-service help hermit-font cst-agent
|
||||
|
||||
all: sashidown-lib cst-agent $(CST_CORE_LIB)
|
||||
all: sashidown-lib hermit $(CST_CORE_LIB)
|
||||
|
||||
$(CST_CORE_LIB): $(CORE_OBJ) FORCE
|
||||
# Compat alias: many scripts still say `make cst-agent`.
|
||||
cst-agent: hermit
|
||||
|
||||
help:
|
||||
@echo "Hermit build targets (prefer: make -j\$$(nproc))"
|
||||
@echo " all / hermit incremental build (ccache auto if installed)"
|
||||
@echo " check build + run unit tests"
|
||||
@echo " asan AddressSanitizer rebuild + check"
|
||||
@echo " hermit-font bake PNG frames + hermit-icons.ttf (Pillow+fonttools)"
|
||||
@echo " install PREFIX=$(PREFIX); hermit + font + cst-agent symlink"
|
||||
@echo " install-service systemd --user parliament unit"
|
||||
@echo "Env: USE_CCACHE=0|1|auto STATIC=1 CST_WEBUI=0 CC=tcc"
|
||||
@echo " HERMIT_ICONS=auto|mini|font HERMIT_*/CST_* env"
|
||||
@echo "Release: scripts/package_linux.sh ARCH | scripts/ci_windows_cross.sh"
|
||||
@echo "Fast install: scripts/bootstrap_linux.sh --release"
|
||||
|
||||
$(CST_CORE_LIB): $(CORE_OBJ)
|
||||
rm -f $@
|
||||
$(AR) rcs $@ $(CORE_OBJ)
|
||||
|
||||
cmake-gcc:
|
||||
cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON
|
||||
cmake --build build-gcc
|
||||
ctest --test-dir build-gcc --output-on-failure
|
||||
cmake --build build-gcc -j
|
||||
ctest --test-dir build-gcc --output-on-failure -j
|
||||
|
||||
cmake-clang:
|
||||
cmake -B build-clang -DCMAKE_C_COMPILER=clang -DCST_BUILD_TUI=ON
|
||||
cmake --build build-clang
|
||||
cmake --build build-clang -j
|
||||
|
||||
sashidown-lib: $(SD_DIR)/libsashidown.a
|
||||
|
||||
# The sub-make decides whether the archive is stale; FORCE only makes sure it
|
||||
# is asked, and -j builds wait for it before linking.
|
||||
$(SD_DIR)/libsashidown.a: FORCE
|
||||
# Always ask the sub-make; it no-ops when libsashidown.a is current.
|
||||
sashidown-lib:
|
||||
$(MAKE) -C $(SD_DIR) libsashidown.a
|
||||
|
||||
FORCE:
|
||||
|
||||
cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
|
||||
hermit: $(OBJ) sashidown-lib
|
||||
$(CC) $(CFLAGS) -o $@ $(OBJ) $(LDFLAGS) $(LDLIBS)
|
||||
@ln -sfr $@ cst-agent 2>/dev/null || ln -sf hermit cst-agent
|
||||
|
||||
hermit-font:
|
||||
@python3 scripts/build_hermit_font.py || \
|
||||
echo "hermit-font: skipped (need Pillow+fonttools; try: nix-shell -p python3Packages.pillow python3Packages.fonttools)"
|
||||
|
||||
# Built-in model data as a C byte list (included by model_registry.c).
|
||||
.gen/models_builtin.inc: src/models_builtin.json
|
||||
@@ -218,11 +263,15 @@ cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
|
||||
@mkdir -p .gen
|
||||
@sh scripts/embed_web.sh $@ $(WEB_DIR)
|
||||
|
||||
%.o: %.c $(HDRS)
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
|
||||
# Incremental rebuilds: -MMD tracks real includes instead of every header.
|
||||
COMPILE_C = $(CC) $(CFLAGS) $(CPPFLAGS) -MMD -MP -c -o $@ $<
|
||||
|
||||
src/tui.o: src/tui.c $(HDRS) $(SD_DIR)/include/sashidown.h
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
|
||||
%.o: %.c
|
||||
$(COMPILE_C)
|
||||
|
||||
src/model_registry.o: .gen/models_builtin.inc
|
||||
src/webui.o: .gen/web_assets.inc
|
||||
src/tui.o: $(SD_DIR)/include/sashidown.h
|
||||
|
||||
tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h $(TEST_OS_SRC)
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_frontier.c src/ctl_parse.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
|
||||
@@ -230,7 +279,7 @@ tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h
|
||||
tests/test_ctl_heredoc: tests/test_ctl_heredoc.c src/ctl_parse.c include/ctl.h $(TEST_OS_SRC)
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_heredoc.c src/ctl_parse.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
|
||||
|
||||
tests/test_json_baseline: tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(HDRS) $(TEST_OS_SRC)
|
||||
tests/test_json_baseline: tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC)
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
|
||||
|
||||
tests/test_replay_starvation: tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC)
|
||||
@@ -251,32 +300,110 @@ tests/test_pdf_text: tests/test_pdf_text.c src/pdf_text.c include/cst_pdf.h $(TE
|
||||
tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm
|
||||
|
||||
tests/test_%: tests/test_%.c $(CORE_SRC) $(HDRS)
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CORE_SRC) $(TEST_LIBS)
|
||||
# Core tests reuse libcstcore.a — avoid recompiling ~35 .c files per binary.
|
||||
# Do not depend on the phony sashidown-lib target or every test relinks each check.
|
||||
tests/test_%: tests/test_%.c $(CST_CORE_LIB) $(SD_DIR)/libsashidown.a
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CST_CORE_LIB) $(TEST_LIBS)
|
||||
|
||||
$(TESTS): sashidown-lib
|
||||
$(SD_DIR)/libsashidown.a: sashidown-lib
|
||||
|
||||
check: $(TESTS)
|
||||
# Build test binaries with make -j; run them one-by-one for readable logs.
|
||||
check: sashidown-lib $(TESTS)
|
||||
@set -e; for t in $(TESTS); do echo "== $$t"; ./$$t; done
|
||||
|
||||
# AddressSanitizer + UBSan build of the agent and all tests.
|
||||
# Under a ptrace-based sandbox (some Termux setups) LeakSanitizer aborts with
|
||||
# "Tracer caught signal 11"; add ASAN_OPTIONS=detect_leaks=0 there.
|
||||
# AddressSanitizer + UBSan rebuild (clears objects so sanitizer flags apply).
|
||||
asan:
|
||||
$(MAKE) clean
|
||||
$(MAKE) sashidown-lib
|
||||
$(MAKE) CFLAGS="-std=c11 -Wall -Wextra -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer" cst-agent check
|
||||
$(MAKE) CFLAGS="-std=c11 -Wall -Wextra -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer" \
|
||||
sashidown-lib hermit check
|
||||
|
||||
demo: cst-agent
|
||||
./cst-agent --demo-ctl --workspace .
|
||||
demo: hermit
|
||||
./hermit --demo-ctl --workspace .
|
||||
|
||||
clean:
|
||||
rm -f $(OBJ) cst-agent $(CST_CORE_LIB) $(TESTS) .gen/models_builtin.inc \
|
||||
.gen/web_assets.inc
|
||||
rm -f $(OBJ) $(DEPFILES) hermit cst-agent $(CST_CORE_LIB) $(TESTS) \
|
||||
.gen/models_builtin.inc .gen/web_assets.inc
|
||||
|
||||
install: cst-agent
|
||||
# Absolute checkout path for hermit-update (survives cd). Override with HERMIT_SRC.
|
||||
HERMIT_SRC ?= $(abspath .)
|
||||
|
||||
install: hermit
|
||||
install -d "$(DESTDIR)$(PREFIX)/bin"
|
||||
install -m 0755 cst-agent "$(DESTDIR)$(PREFIX)/bin/cst-agent"
|
||||
install -m 0755 hermit "$(DESTDIR)$(PREFIX)/bin/hermit"
|
||||
ln -sfr "$(DESTDIR)$(PREFIX)/bin/hermit" "$(DESTDIR)$(PREFIX)/bin/cst-agent" 2>/dev/null || \
|
||||
ln -sf hermit "$(DESTDIR)$(PREFIX)/bin/cst-agent"
|
||||
@# hermit-update / hermit-service (cst-* compat symlinks)
|
||||
@printf '%s\n' '#!/usr/bin/env bash' \
|
||||
'export HERMIT_PREFIX="$${HERMIT_PREFIX:-$${CST_AGENT_PREFIX:-$(PREFIX)}}"' \
|
||||
'export CST_AGENT_PREFIX="$$HERMIT_PREFIX"' \
|
||||
'export HERMIT_DIR="$${HERMIT_DIR:-$${CST_AGENT_DIR:-$(HERMIT_SRC)}}"' \
|
||||
'export CST_AGENT_DIR="$$HERMIT_DIR"' \
|
||||
'exec bash "$$HERMIT_DIR/scripts/bootstrap_linux.sh" "$$HERMIT_DIR" "$$@"' \
|
||||
> "$(DESTDIR)$(PREFIX)/bin/hermit-update"
|
||||
@chmod 0755 "$(DESTDIR)$(PREFIX)/bin/hermit-update"
|
||||
@ln -sfr "$(DESTDIR)$(PREFIX)/bin/hermit-update" "$(DESTDIR)$(PREFIX)/bin/cst-agent-update" 2>/dev/null || \
|
||||
ln -sf hermit-update "$(DESTDIR)$(PREFIX)/bin/cst-agent-update"
|
||||
@printf '%s\n' '#!/usr/bin/env bash' \
|
||||
'set -euo pipefail' \
|
||||
'BIN="$(PREFIX)/bin/hermit"' \
|
||||
'cmd="$${1:-status}"' \
|
||||
'case "$$cmd" in' \
|
||||
'status) "$$BIN" --version; if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active hermit-parliament.service >/dev/null 2>&1; then systemctl --user status hermit-parliament.service --no-pager; elif command -v systemctl >/dev/null 2>&1 && systemctl --user is-active cst-agent-parliament.service >/dev/null 2>&1; then systemctl --user status cst-agent-parliament.service --no-pager; else "$$BIN" --service-stop >/dev/null 2>&1 || true; echo "parliament service: not managed by systemd --user"; fi ;;' \
|
||||
'install-unit) make -C "$(HERMIT_SRC)" install-service PREFIX="$(PREFIX)" ;;' \
|
||||
'start) systemctl --user start hermit-parliament.service 2>/dev/null || systemctl --user start cst-agent-parliament.service ;;' \
|
||||
'stop) systemctl --user stop hermit-parliament.service 2>/dev/null || true; systemctl --user stop cst-agent-parliament.service 2>/dev/null || true; "$$BIN" --service-stop || true ;;' \
|
||||
'restart) systemctl --user restart hermit-parliament.service 2>/dev/null || systemctl --user restart cst-agent-parliament.service ;;' \
|
||||
'*) echo "usage: hermit-service {status|install-unit|start|stop|restart}" >&2; exit 2 ;;' \
|
||||
'esac' \
|
||||
> "$(DESTDIR)$(PREFIX)/bin/hermit-service"
|
||||
@chmod 0755 "$(DESTDIR)$(PREFIX)/bin/hermit-service"
|
||||
@ln -sfr "$(DESTDIR)$(PREFIX)/bin/hermit-service" "$(DESTDIR)$(PREFIX)/bin/cst-agent-service" 2>/dev/null || \
|
||||
ln -sf hermit-service "$(DESTDIR)$(PREFIX)/bin/cst-agent-service"
|
||||
@if [ ! -f res/hermit-icons.ttf ]; then $(MAKE) hermit-font || true; fi
|
||||
@if [ -f res/hermit-icons.ttf ]; then \
|
||||
install -d "$(DESTDIR)$(PREFIX)/share/hermit"; \
|
||||
install -d "$(DESTDIR)$(PREFIX)/share/fonts/hermit"; \
|
||||
install -m 0644 res/hermit-icons.ttf "$(DESTDIR)$(PREFIX)/share/hermit/hermit-icons.ttf"; \
|
||||
install -m 0644 res/hermit-icons.ttf "$(DESTDIR)$(PREFIX)/share/fonts/hermit/hermit-icons.ttf"; \
|
||||
if [ -n "$(HOME)" ] && [ "$(DESTDIR)" = "" ]; then \
|
||||
install -d "$(HOME)/.local/share/fonts"; \
|
||||
install -m 0644 res/hermit-icons.ttf "$(HOME)/.local/share/fonts/hermit-icons.ttf"; \
|
||||
command -v fc-cache >/dev/null 2>&1 && fc-cache -f "$(HOME)/.local/share/fonts" >/dev/null 2>&1 || true; \
|
||||
fi; \
|
||||
echo "installed Hermit Icons → $(PREFIX)/share/fonts/hermit/hermit-icons.ttf"; \
|
||||
else \
|
||||
echo "warning: res/hermit-icons.ttf missing (run: make hermit-font)" >&2; \
|
||||
fi
|
||||
@if [ -d res/hermit-frames ]; then \
|
||||
install -d "$(DESTDIR)$(PREFIX)/share/hermit/frames"; \
|
||||
cp -a res/hermit-frames/. "$(DESTDIR)$(PREFIX)/share/hermit/frames/"; \
|
||||
fi
|
||||
@echo "installed hermit-update → $(PREFIX)/bin/hermit-update"
|
||||
|
||||
# Optional systemd --user unit for the parliament seat service.
|
||||
install-service: install
|
||||
install -d "$(DESTDIR)$(HOME)/.config/systemd/user"
|
||||
sed -e 's|@BIN@|$(PREFIX)/bin/hermit|g' \
|
||||
scripts/hermit-parliament.service.in \
|
||||
> "$(DESTDIR)$(HOME)/.config/systemd/user/hermit-parliament.service"
|
||||
ln -sfr "$(DESTDIR)$(HOME)/.config/systemd/user/hermit-parliament.service" \
|
||||
"$(DESTDIR)$(HOME)/.config/systemd/user/cst-agent-parliament.service" 2>/dev/null || \
|
||||
ln -sf hermit-parliament.service \
|
||||
"$(DESTDIR)$(HOME)/.config/systemd/user/cst-agent-parliament.service"
|
||||
@echo "Installed user unit. Enable with:"
|
||||
@echo " systemctl --user daemon-reload"
|
||||
@echo " systemctl --user enable --now hermit-parliament.service"
|
||||
@echo "(set HERMIT_/CST_ REALM_PASSWORD / INSTANCE_ID in the drop-in or environment)"
|
||||
|
||||
uninstall:
|
||||
rm -f "$(DESTDIR)$(PREFIX)/bin/cst-agent"
|
||||
rm -f "$(DESTDIR)$(PREFIX)/bin/hermit" "$(DESTDIR)$(PREFIX)/bin/cst-agent"
|
||||
rm -f "$(DESTDIR)$(PREFIX)/bin/hermit-update" "$(DESTDIR)$(PREFIX)/bin/cst-agent-update"
|
||||
rm -f "$(DESTDIR)$(PREFIX)/bin/hermit-service" "$(DESTDIR)$(PREFIX)/bin/cst-agent-service"
|
||||
rm -f "$(DESTDIR)$(PREFIX)/share/hermit/hermit-icons.ttf"
|
||||
rm -f "$(DESTDIR)$(PREFIX)/share/fonts/hermit/hermit-icons.ttf"
|
||||
rm -rf "$(DESTDIR)$(PREFIX)/share/hermit/frames"
|
||||
rm -f "$(HOME)/.local/share/fonts/hermit-icons.ttf"
|
||||
rm -f "$(HOME)/.config/systemd/user/hermit-parliament.service"
|
||||
rm -f "$(HOME)/.config/systemd/user/cst-agent-parliament.service"
|
||||
|
||||
-include $(DEPFILES)
|
||||
|
||||
@@ -1,542 +1,94 @@
|
||||
# CST-Agent
|
||||
# Hermit
|
||||
|
||||
C11 OpenCode-like carrier that extends paper **CST** (confirmation frontier / irreversible emission) from Markdown→HTML to the **agent tool loop**.
|
||||
> **仓库已更名。** 规范地址:[WHC/Hermit](https://git.whcself.xyz/WHC/Hermit)。
|
||||
> 本仓库 `CST-agent` 仅作迁移兼容入口。换远程:`./scripts/migrate_remote_to_hermit.sh`
|
||||
> 完整步骤:[MIGRATION.md](MIGRATION.md)。
|
||||
|
||||
See [docs/theory_tool_cst.md](docs/theory_tool_cst.md).
|
||||
评测/反哺论文素材:[docs/eval_tool_cst.md](docs/eval_tool_cst.md)。
|
||||
本地 coding agent(原 CST-Agent):终端 TUI、可选 WebUI / 桌面 GUI,在工作区里读改文件、跑命令、管会话。
|
||||
C11 实现,OpenAI 兼容网关(DeepSeek / Kimi / MiniMax / OpenAI 等)。
|
||||
|
||||
## 能不能用来工作?
|
||||
**适合**:有监督的本地改动与仓库探索(权限确认、diff、`/undo`、沙箱)。
|
||||
**慎用**:完全无人值守的长任务;headless 默认自动批准。
|
||||
|
||||
**可以**:有监督的本地改动 / 探索仓库(TUI + 13 个内置工具 + 带 diff 的权限确认 + `/undo`)。
|
||||
**不宜**:无人值守长任务或当 CI 主力(网关偶发空答;headless 模式默认自动批准)。
|
||||
文档索引:[docs/README.md](docs/README.md)。
|
||||
|
||||
### 5 分钟试用
|
||||
兼容:安装后提供 `cst-agent` → `hermit` 软链;环境变量 `HERMIT_*` 优先,仍接受 `CST_*`;配置目录优先 `~/.config/hermit`,若无则回退 `~/.config/cst-agent`。
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
### Linux / WSL / macOS
|
||||
|
||||
```bash
|
||||
# Linux / WSL / macOS 一键:克隆到 ~/CST-agent、编译、装到 ~/.local/bin
|
||||
curl -fsSL https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/bootstrap_linux.sh | bash
|
||||
# 预编译包(快;失败则回退源码)— 规范仓库 Hermit
|
||||
curl -fsSL https://git.whcself.xyz/WHC/Hermit/raw/branch/main/scripts/bootstrap_linux.sh | bash -s -- --release
|
||||
|
||||
# 已有仓库目录内(装到别处:CST_AGENT_PREFIX=/usr/local)
|
||||
# 源码增量编译 → ~/.local/bin/hermit(+ cst-agent 软链)
|
||||
curl -fsSL https://git.whcself.xyz/WHC/Hermit/raw/branch/main/scripts/bootstrap_linux.sh | bash
|
||||
```
|
||||
|
||||
```bash
|
||||
./scripts/bootstrap_linux.sh .
|
||||
|
||||
# 以后升级:拉取最新代码、重新编译并安装(本地有未提交改动时跳过 pull)
|
||||
cst-agent-update
|
||||
cst-agent --version
|
||||
|
||||
# 在项目目录里直接启动:首次会让你选 provider 并输入 API key(遮罩输入,按 provider 保存)
|
||||
cd ~/my-project && cst-agent
|
||||
hermit --version # hermit 1.0.x
|
||||
hermit-update # 升级(源码或 --release);兼容 cst-agent-update
|
||||
./scripts/migrate_remote_to_hermit.sh # 已有 CST-agent 检出时改 origin
|
||||
```
|
||||
|
||||
不想编译:[Release](https://git.whcself.xyz/WHC/CST-agent/releases) 里有静态链接的 Linux 单文件
|
||||
(`cst-agent-VERSION-linux-{x86_64,x86,aarch64}.tar.gz`,musl,任何发行版都能直接跑),解压后把 `cst-agent`
|
||||
放进 `PATH` 即可;每个文件旁边有 `.sha256`。
|
||||
[Release](https://git.whcself.xyz/WHC/Hermit/releases)(旧 [CST-agent releases](https://git.whcself.xyz/WHC/CST-agent/releases) 过渡期可能仍有镜像):`hermit-VERSION-linux-{x86_64,x86,aarch64}.tar.gz`。
|
||||
|
||||
Windows 10 1903+(PowerShell,不需要管理员):
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
irm https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/install_windows.ps1 | iex
|
||||
# 新开终端后:cd 到项目目录运行 cst-agent;升级用 cst-agent-update
|
||||
```
|
||||
见 [docs/README-windows.md](docs/README-windows.md)(产物名逐步改为 `hermit-…`;旧 `cst-agent-…` 包过渡期可能仍有)。
|
||||
|
||||
也可以下载 Release 里的安装包 `cst-agent-VERSION-windows-x86_64-setup.exe`(按用户安装、自动加 PATH、
|
||||
开始菜单有 WebUI 入口),或便携版 zip。详见 [docs/README-windows.md](docs/README-windows.md)。
|
||||
|
||||
一键脚本可以重复运行:已有仓库会 `git pull --ff-only`,代码有更新时干净重编,然后重新安装;
|
||||
同时装一个 `cst-agent-update` 命令(等同重跑脚本)。NixOS 上会自动进 `nix-shell` 编译。
|
||||
|
||||
之后再启动会沿用上次的 provider、模型和推理强度(low / medium / high / max),不需要任何参数或环境变量。
|
||||
界面里用 `/provider` 切换(没保存过 key 的会当场询问),`/model`、`/effort` 切换模型和推理强度,`/key` 更换 key。
|
||||
|
||||
完整环境变量说明:[docs/env_openai.md](docs/env_openai.md)。
|
||||
|
||||
| 平台 | 一键脚本 |
|
||||
|---|---|
|
||||
| Linux | [`scripts/bootstrap_linux.sh`](scripts/bootstrap_linux.sh) |
|
||||
| Termux | [`scripts/bootstrap_termux.sh`](scripts/bootstrap_termux.sh) |
|
||||
| Windows 安装 / 升级 | [`scripts/install_windows.ps1`](scripts/install_windows.ps1)(从 Release 安装),之后 `cst-agent-update` |
|
||||
| Linux 静态发行包(musl 交叉) | [`scripts/package_linux.sh`](scripts/package_linux.sh) `x86_64\|x86\|aarch64` → `dist/*.tar.gz` |
|
||||
| Windows 交叉构建(在 Linux 上) | [`scripts/bootstrap_windows.sh`](scripts/bootstrap_windows.sh) → `dist/windows-x86_64/`、zip 和安装包 |
|
||||
| Windows 原生构建(MSYS2 UCRT64) | [`scripts/bootstrap_msys2.sh`](scripts/bootstrap_msys2.sh) → `dist/windows-x86_64/` |
|
||||
|
||||
### 迷你验收工程(论文案例)
|
||||
|
||||
故意带 bug 的小计算器:[`fixtures/mini_calc/`](fixtures/mini_calc/)(任务书 `TASK.md`)。
|
||||
### 第一次用
|
||||
|
||||
```bash
|
||||
./scripts/e2e_mini_calc.sh --baseline-only # 无 API:证明初始测试 RED
|
||||
./scripts/e2e_mini_calc.sh --provider deepseek --protocol ctl # 有 key
|
||||
cd ~/my-project && hermit
|
||||
```
|
||||
|
||||
更多夹具(每个都有 `TASK.md` + `check.sh`):`makefile_tab`(写含 TAB 的 Makefile)、
|
||||
`big_file`(写 5000 字节文件)、`test_fix_test`(先跑测试→修→再跑测试)、
|
||||
`multi_file_refactor`(跨文件改名 + 修 bug,适合 `edit_file` / `grep`)、
|
||||
`ask_or_todo`(必须用 `todo_write` 规划并全部完成;无人回答 `ask_user`)。
|
||||
后两个需要 `--tools default`(脚本默认 `minimal`,即论文用的 4 个工具)。
|
||||
---
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
scripts/e2e_fixture.sh makefile_tab --provider deepseek --protocol native-inc
|
||||
scripts/e2e_fixture.sh multi_file_refactor --provider deepseek --tools default
|
||||
# 批量实验:协议 x 模型 x 夹具 x N 次 → results.csv + summary.md
|
||||
scripts/run_matrix.sh -p "native-eos native-inc ctl ctl-heredoc xml" \
|
||||
-m "deepseek:deepseek-flash deepseek:deepseek-v4-pro" -n 3 -r
|
||||
make -j$(nproc) # 产出 hermit(并链 cst-agent)
|
||||
make -j$(nproc) check
|
||||
make hermit-font # 烘焙 PNG 帧 + hermit-icons.ttf(Pillow + fonttools)
|
||||
make install PREFIX=$HOME/.local # 同时安装字体到 share/fonts/hermit 与 ~/.local/share/fonts
|
||||
```
|
||||
|
||||
口径:`pass` / `fail` / `agent_error` / `timeout` — 见 [docs/eval_tool_cst.md](docs/eval_tool_cst.md)。
|
||||
桌面 GUI(Tauri 2 + React,DeepSeek Harness 风格布局):见 [`apps/desktop`](apps/desktop/) 与 [docs/desktop.md](docs/desktop.md)。旧 Qt App(`app/`)已冻结。
|
||||
|
||||
## Build
|
||||
TUI 图标:`HERMIT_ICONS=auto|mini|font`(默认色块动画帧,状态与 `icons/agent-motion` HTML 套件一致;`font` 输出 PUA `U+E700+`,需终端加载已安装的 Hermit Icons)。环境变量 **`HERMIT_*` 优先,`CST_*` 仍接受**。
|
||||
|
||||
### Make(Linux 默认)
|
||||
---
|
||||
|
||||
## 用法速览
|
||||
|
||||
```bash
|
||||
cd CST-agent
|
||||
make # 依赖 pkg-config 找 libcurl / ncursesw(找不到时退回 ncurses)
|
||||
make check # 单元测试 + 截断实验(含真实录制流)
|
||||
make asan # AddressSanitizer + UBSan 构建并跑全部测试
|
||||
make CC=tcc AR=ar check # TinyCC:整套构建 + 测试十几秒(CI 的校验就是这条)
|
||||
make install PREFIX=$HOME/.local # 或 sudo make install(默认 /usr/local)
|
||||
hermit # TUI
|
||||
hermit --web # http://127.0.0.1:8787/
|
||||
hermit --headless --prompt '…'
|
||||
```
|
||||
|
||||
NixOS:`nix-shell`(仓库自带 [`shell.nix`](shell.nix))后再 `make`。
|
||||
macOS:`brew install curl ncurses pkg-config` 后直接 `make`(Makefile 会找 Homebrew 的 keg-only 库)。
|
||||
各平台细节与验收清单见 [docs/compat.md](docs/compat.md)。
|
||||
首页品牌 **HERMIT** + 寄居蟹色块动效;色板:brand `#277F94`、crab `#F68042`、eye `#9957AD`。
|
||||
|
||||
### CMake(推荐跨编译器 / 交叉)
|
||||
更多:Provider / 工具 / 议会 / WebUI 见 [docs/](docs/)。
|
||||
|
||||
```bash
|
||||
# GCC
|
||||
cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON
|
||||
cmake --build build-gcc && ctest --test-dir build-gcc
|
||||
---
|
||||
|
||||
# Clang
|
||||
cmake -B build-clang -DCMAKE_C_COMPILER=clang -DCST_BUILD_TUI=ON
|
||||
cmake --build build-clang
|
||||
## CI 与发布
|
||||
|
||||
# Headless only(无 ncurses)
|
||||
cmake -B build-hd -DCST_BUILD_TUI=OFF && cmake --build build-hd
|
||||
`.gitea/workflows/ci.yml`:TinyCC check → 打 Linux/Windows 包 → `scripts/ci_publish.sh`。
|
||||
共用逻辑:[scripts/lib_release.sh](scripts/lib_release.sh)。
|
||||
|
||||
# Windows:用一键脚本(见下方“Windows 发行包”),会先编译静态依赖
|
||||
./scripts/bootstrap_windows.sh
|
||||
```
|
||||
|
||||
依赖:`gcc`、`clang` 或 `tcc`(0.9.27 mob 以上)、`libcurl`、TUI 需 `ncursesw`(Windows 版用打过补丁的 ncurses 6.6,脚本自动编译)。
|
||||
### 桌面 GUI(Qt/QML,可选)
|
||||
|
||||
把引擎放进同一个进程的窗口前端(无端口、无第二个进程):`app/`,细节见 [app/README.md](app/README.md)。
|
||||
|
||||
```bash
|
||||
# NixOS / nix:装进用户 profile(PATH + 菜单项 + 图标),卸载 nix profile remove cst-agent
|
||||
nix profile add --file .
|
||||
|
||||
# 其他发行版(需要 Qt 6 的 Quick/Qml/QuickControls2 与 libcurl)
|
||||
cmake -B build-app -DCST_BUILD_APP=ON -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build-app -j && cmake --install build-app --component app --prefix ~/.local
|
||||
```
|
||||
|
||||
菜单里启动时工作区=启动目录(即 `$HOME`);在项目目录里 `cst-agent-app` 启动则工作区就是那个目录。
|
||||
|
||||
Sashidown(`vendor/sashidown`)与 cJSON(`vendor/cjson`,MIT)已随仓库提供。
|
||||
API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。
|
||||
`--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。
|
||||
|
||||
## 平台支持矩阵
|
||||
|
||||
| OS | Arch | 编译器 | Headless | TUI | Shell |
|
||||
|---|---|---|---|---|---|
|
||||
| Linux(glibc / musl) | x86_64 / x86 / aarch64 | GCC, Clang, TinyCC | yes | ncursesw | `/bin/sh` 或旁路 `busybox` |
|
||||
| WSL2 | x86_64 | 同 Linux | yes | ncursesw | `/bin/sh`(工作区建议放在 Linux 文件系统内) |
|
||||
| macOS | arm64 / x86_64 | Apple Clang | yes | ncurses(Homebrew) | `/bin/sh` |
|
||||
| Android Termux | aarch64 / … | Clang (Termux) | yes | ncurses(Termux 包) | `$PREFIX/bin/sh`;可旁路 busybox |
|
||||
| Windows 10 1903+ | x86_64 | MinGW-w64 GCC(UCRT) | yes | ncurses 6.6(Windows 控制台) | **捆绑 busybox-w32** |
|
||||
|
||||
### WebUI(`--web`)
|
||||
|
||||
同一个二进制内置一个 HTTP + SSE 前端:`cst-agent --web`(默认 `http://127.0.0.1:8787/`)。
|
||||
它和 TUI 是同一引擎的两个前端,浏览器里能流式看回答、点审批、切会话、贴图。
|
||||
协议是一份稳定的 JSON + SSE 接口,供以后的专用客户端复用。详见 [docs/webui.md](docs/webui.md)。
|
||||
|
||||
```bash
|
||||
cst-agent --web # 本机浏览器打开 http://127.0.0.1:8787/
|
||||
cst-agent --web --web-port 0 # 端口交给系统,启动日志里给 URL
|
||||
cst-agent --web --web-dev ./web # 开发前端:直接读目录,改完刷新
|
||||
cst-agent --web --web-host 0.0.0.0 --web-token 口令 # 手机/局域网访问
|
||||
sh scripts/e2e_webui.sh # 用录制流跑完整轮次,不需要 API key
|
||||
```
|
||||
|
||||
不带 TUI 的构建也可以开 WebUI(`cmake -DCST_BUILD_WEBUI=ON -DCST_BUILD_TUI=OFF`);
|
||||
`-DCST_BUILD_WEBUI=OFF` 时 `--web` 只打印一条提示。
|
||||
|
||||
### Termux
|
||||
|
||||
```bash
|
||||
# 一键:装依赖、克隆到 ~/CST-agent、编译、安装到 $PREFIX/bin
|
||||
curl -fsSL https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/bootstrap_termux.sh | bash
|
||||
|
||||
# 以后升级(重跑上面那条也一样)
|
||||
cst-agent-update
|
||||
|
||||
# 或手动
|
||||
pkg install git clang make pkg-config binutils libcurl ncurses
|
||||
git clone https://git.whcself.xyz/WHC/CST-agent.git && cd CST-agent
|
||||
make && make install # 装到 Termux 的 $PREFIX/bin,之后任意目录运行 cst-agent
|
||||
# 可选:旁路 Unix busybox
|
||||
# ./scripts/build_third_party_mingw.sh busybox-unix
|
||||
```
|
||||
|
||||
环境:`PREFIX` 自动用于找 `sh`;`CST_EXE_DIR` / `argv[0]` 用于定位旁路 busybox(`/proc/self/exe` 受限时)。
|
||||
临时目录:依次试 `$TMPDIR`、`$PREFIX/tmp`、`/tmp`、`/var/tmp`、`~/.cache/cst-agent/tmp`,用第一个真能建文件的,
|
||||
并写回 `TMPDIR` 给 shell 子进程和沙箱用(Termux 的 `/tmp` 常常不存在或被 SELinux 拒绝)。
|
||||
|
||||
### Linux 静态发行包
|
||||
|
||||
```bash
|
||||
scripts/package_linux.sh x86_64 # 或 x86 / aarch64;CST_LINUX_CHECK=1 顺便跑 make check
|
||||
```
|
||||
|
||||
用 Bootlin 的 musl 交叉 gcc(固定版本、校验 sha256)静态编译 mbedTLS、curl 8.22(只开 HTTP/HTTPS)和宽字符 ncurses,
|
||||
再以 `STATIC=1` 和 `-Werror` 编出 `cst-agent`,产物是 `dist/cst-agent-VERSION-linux-ARCH.tar.gz`(+ `.sha256`)。
|
||||
宿主需要 `cmake ninja make perl` 以及 `tic`/`infocmp` 和终端条目(Debian:`ncurses-bin ncurses-term`)。
|
||||
ncurses 会去常见目录找 terminfo,找不到时用编进去的 `xterm-256color`、`xterm`、`screen-256color`、`tmux-256color`、`linux`、`vt100`。
|
||||
CA 证书:`CST_CA_BUNDLE` → `SSL_CERT_FILE` → 各发行版的常见位置(Debian/Ubuntu、Fedora/RHEL、openSUSE、Alpine)。
|
||||
|
||||
### Windows 发行包
|
||||
|
||||
见 [docs/README-windows.md](docs/README-windows.md)(安装、编码、沙箱、构建、已知限制)。
|
||||
|
||||
```bash
|
||||
./scripts/bootstrap_windows.sh # Linux 交叉编译;Windows 上在 MSYS2 UCRT64 里用 scripts/bootstrap_msys2.sh
|
||||
```
|
||||
|
||||
产物:`dist/windows-x86_64/`(静态链接的 `cst-agent.exe` + `busybox.exe`,无其他 DLL)、
|
||||
`dist/cst-agent-VERSION-windows-x86_64.zip`,以及装了 `makensis` 时的安装包 `dist/cst-agent-VERSION-windows-x86_64-setup.exe`。
|
||||
|
||||
### CI 与 Release
|
||||
|
||||
CI 用 TinyCC 校验(构建、`make check`、WebUI 端到端),通过后用 gcc(`-Werror`)出发行包:
|
||||
Linux x86_64 / x86 / aarch64 静态包(x86 两种跑 `make check`,aarch64 用 qemu 冒烟),
|
||||
Windows x86_64 的 zip 和安装包(Debian 的 mingw-w64 UCRT gcc + NSIS)。
|
||||
推送到 `main` 或 `v*` tag 时每个任务把自己的产物上传到 Gitea Release `vVERSION`(同名资源会被替换);
|
||||
仓库可设 `RELEASE_TOKEN`(`write:repository`),否则用任务自带 token。
|
||||
本地:`GITEA_TOKEN=... scripts/release.sh dist/cst-agent-*.tar.gz dist/cst-agent-*.zip dist/cst-agent-*-setup.exe`。
|
||||
|
||||
环境变量:`CST_SHELL`、`CST_EXE_DIR`、`CST_AUTO_APPROVE` 等。
|
||||
|
||||
## Defaults (API)
|
||||
|
||||
`--provider`(或 `CST_PROVIDER`)选择预设;显式的 `--api-base` / `--model` / 环境变量优先。
|
||||
|
||||
| provider | API Base | 默认模型 | Key 变量 |
|
||||
|---|---|---|---|
|
||||
| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` |
|
||||
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
|
||||
| `kimi` | `https://api.moonshot.ai/v1` | `kimi-k3`(另有 `kimi-k2.6`、`kimi-k2.7-code`) | `MOONSHOT_API_KEY` |
|
||||
| `kimi-code` | `https://api.kimi.ai/coding/v1` | `k3`(另有 `k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`) | `KIMI_API_KEY` |
|
||||
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
|
||||
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
|
||||
|
||||
| 用途 | 来源(优先级从高到低) |
|
||||
|---|---|
|
||||
| Provider | `--provider` → `CST_PROVIDER` → 上次使用 → `minimax` |
|
||||
| Key | `--api-key` → 已保存的 key(`credentials`)→ provider 的 Key 变量 → `CST_API_KEY` → TUI 询问 |
|
||||
| Base(含 `/v1`) | `--api-base` → `CST_API_BASE` → `OPENAI_BASE_URL` → `OPENAI_API_BASE` → 预设 |
|
||||
| Model | `--model` → `CST_MODEL` → `OPENAI_MODEL` → 该 provider 上次使用的模型 → 预设 |
|
||||
| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium` |
|
||||
|
||||
“上次使用”记在配置目录的 `settings.json`(TUI 里切换时更新;headless 运行不改它)。
|
||||
推理强度会按 provider 适配:DeepSeek/OpenAI 使用 `reasoning_effort`,Kimi K3 使用同名字段,Kimi K2 使用 `thinking` 并保留 `reasoning_content`。Kimi 默认开启 1 小时隐式 prompt cache,可用 `CST_KIMI_CACHE_TTL=5m|1h` 调整,或用 `CST_NO_CACHE_HINTS=1` 关闭提示。
|
||||
|
||||
Kimi 与 DeepSeek 还提供官方联网工具:`web_search` 调用 provider 的 Search API,`web_fetch` 调用 provider 的 Fetch API;其它 provider 继续使用通用 HTTP 抓取。
|
||||
|
||||
`kimi` 是按量计费的 Kimi 开放平台,`kimi-code` 是会员订阅的 Kimi Code API;两者的 Base URL 和 API Key 不通用。
|
||||
|
||||
详见 [docs/env_openai.md](docs/env_openai.md)。
|
||||
|
||||
| 变量 | 作用(默认) |
|
||||
|---|---|
|
||||
| `CST_PROTOCOL` / `--protocol` | 工具调用协议(`native-eos`) |
|
||||
| `CST_TOOLS` / `--tools` | 工具集:`default`(全部)/ `minimal`(论文 4 件套) |
|
||||
| `CST_MODE` / `--mode` | 工作模式:`coding`(默认,代码口径)/ `general`(通用助手口径) |
|
||||
| `CST_EFFORT` / `--effort` | 推理强度 `low` / `medium` / `high` / `max` |
|
||||
| `CST_CONFIRM` / `--confirm` | 哪些类别要确认:`shell,write,net`(默认)或 `none` |
|
||||
| `CST_MAX_STEPS` / `--max-steps` | 每个用户回合最多调用模型几次(30) |
|
||||
| `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(16384,保留头 60% + 尾 40%) |
|
||||
| `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(80) |
|
||||
| `CST_CTX_TOKENS` / `CST_AUTO_COMPACT=0` | 覆盖模型上下文窗口大小 / 关闭自动压缩(用到 75% 时自动 `/compact`) |
|
||||
| `CST_NO_PROJECT_NOTES=1` | 不把工作区的 `AGENTS.md` 注入 system prompt |
|
||||
| `NO_COLOR` / `CST_ASCII=1` | 关闭颜色 / 强制 ASCII 图形(非 UTF-8 终端自动退化) |
|
||||
| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(默认不限),设了才在超时后整组杀进程,exit=124 |
|
||||
| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/<session>.jsonl`) |
|
||||
| `CST_RECORD_SSE=DIR` / `CST_REPLAY_SSE=DIR` | 录制 / 回放原始 SSE 流(`NNNN.sse` + `NNNN.req.json`) |
|
||||
| `CST_APPROVAL=ask\|auto\|yolo` / `CST_SANDBOX=off\|landlock\|seccomp` | 审批模式 / 关闭沙箱(回落到只读白名单规则)或只用指定沙箱 |
|
||||
| `CST_CA_BUNDLE` | HTTPS 用的 CA 证书文件(默认:`SSL_CERT_FILE`、libcurl 自带路径、常见发行版位置;Windows 用系统证书库) |
|
||||
| `CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR` | 模型列表、自动批准(= yolo)、shell、旁路 busybox |
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# TUI(工作目录 = 当前目录;Assistant Markdown 经 Sashidown CST 渲染)
|
||||
cst-agent
|
||||
|
||||
# Local CTL demo (no API)
|
||||
cst-agent --demo-ctl
|
||||
|
||||
# 在命令行输入并保存当前 provider 的 key(不回显)
|
||||
cst-agent --ask-key --provider deepseek
|
||||
|
||||
# Headless(默认 auto-approve shell/写文件)
|
||||
cst-agent --headless --prompt 'List files'
|
||||
```
|
||||
|
||||
会话相关命令行参数:
|
||||
|
||||
| 参数 | 作用 |
|
||||
|---|---|
|
||||
| `-c` / `--continue` | 打开上次的会话(默认行为) |
|
||||
| `-N` / `--new` | 开新会话 |
|
||||
| `-R ID` / `--resume ID` | 打开指定会话(完整 id 或唯一前缀) |
|
||||
| `-L` / `--list-sessions` | 列出会话(时间、条数、标题)后退出 |
|
||||
| `--migrate-store` | 把旧版本的会话(扁平 `<id>.jsonl`)搬进新布局后退出 |
|
||||
| `-A MODE` / `--approval MODE` | 审批模式 `ask` / `auto`(默认)/ `yolo`,见下文 |
|
||||
| `-y` / `--yolo` | 等同 `--approval yolo`,只对本次运行有效 |
|
||||
| `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` |
|
||||
| `-T PROFILE` / `--tools PROFILE` | `default` 或 `minimal` |
|
||||
| `-M MODE` / `--mode MODE` | 工作模式 `coding`(默认)/ `general`,也读 `CST_MODE`,并记进 `settings.json` |
|
||||
| `-P NAME` / `-m NAME` / `-e LEVEL` | 本次使用的 provider / 模型 / 推理强度(不传则用上次的) |
|
||||
| `-a` / `--ask-key` | 在命令行输入并保存当前 provider 的 key |
|
||||
|
||||
工作目录默认就是当前目录(`-w DIR` 可指定别的目录)。
|
||||
|
||||
会话落盘:一个会话一个目录 `<workspace>/.agent/sessions/<id>/`(`session.jsonl` 转录、`title`、`attachments/`、子 agent 的 `sub-N.jsonl`)+ `CURRENT` 指向下次打开的会话;id 是 uuid,两台机器共用一份工作目录也不会撞。旧版本写的扁平会话仍然可读,`--migrate-store` 一次搬进新布局(幂等)。`.agent/` 自带 `.gitignore`,不会被误提交。
|
||||
|
||||
### 内置工具
|
||||
|
||||
| 工具 | 类别 | 说明 |
|
||||
|---|---|---|
|
||||
| `read_file` | 读 | 支持 `offset` / `limit` 分段读和行号 |
|
||||
| `list_dir` | 读 | 目录树(`depth`),遵守 `.gitignore`,跳过 `.git` / `node_modules` 等 |
|
||||
| `glob` | 读 | 按通配符找文件(支持 `**`) |
|
||||
| `grep` | 读 | 正则搜索内容,可限定路径 / glob、忽略大小写、带上下文 |
|
||||
| `write_file` | 写 | 整文件写入(自动建父目录) |
|
||||
| `edit_file` | 写 | 精确片段替换(必须唯一,或 `replace_all`) |
|
||||
| `apply_patch` | 写 | 应用 unified diff,多文件原子生效 |
|
||||
| `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 |
|
||||
| `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 |
|
||||
| `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 |
|
||||
| `web_search` | 网络 | 使用 Kimi 或 DeepSeek 的官方 Search API 搜索实时网页 |
|
||||
| `download` | 网络 | 把 URL 抓成工作区里的文件(≤128 MB,超限删掉残file);字节落在磁盘上,不进模型上下文 |
|
||||
| `read_pdf` | 读 | 读 PDF 的文本层(`read_file` 因二进制拒收的缺口),带页数,文本默认截到 256 KiB |
|
||||
| `clipboard_read` | 读 | 读系统剪贴板里的文本(`max_bytes` 默认 64 KiB,超出截断并说明) |
|
||||
| `clipboard_write` | 写 | 把文本放进系统剪贴板(按写类权限确认;本机走 pbcopy / wl-copy / xclip / Win32 API) |
|
||||
| `todo_write` | 元 | 维护任务清单(TUI 右侧面板,Ctrl+T 开关) |
|
||||
| `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 |
|
||||
| `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 |
|
||||
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
|
||||
| `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 |
|
||||
| `md_to_html` | 文档 | 把 markdown(文本或工作区 `.md`)渲染成一份自包含 HTML 文档写进工作区并交付成文件卡:utf-8、标题、内联样式,无脚本无外链 |
|
||||
| `md_to_pdf` | 文档 | 图形前端(桌面 / APK)自带:把 markdown 或工作区 `.md` / `.html` 排成 A4 PDF(15 mm 边距,自动分页)写进工作区并交付成文件卡;不需要 Qt PrintSupport,手机上也能出 |
|
||||
| `session_search` | 读 | 在过去和当前的所有会话转录里搜子串,给会话 id、条数、标题和匹配行(行号 + 角色 + 上下文);有预算:`max_bytes`(默认 32 MB)、`max_seconds`(默认 5 s),超 64 KiB 的单行跳过,被预算截断时结果里写清楚 |
|
||||
| `device_info` | 读 | 报告运行环境:系统 / 架构 / CPU 数、工作区、沙箱、模型与审批模式、config / temp / 程序目录、pid(审批策略名 `device`) |
|
||||
|
||||
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
|
||||
同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。
|
||||
|
||||
`--tools minimal` 只保留 `read_file` / `run_shell` / `write_file` / `apply_patch`(论文实验口径)。
|
||||
TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
|
||||
|
||||
### 工作模式(coding / general)
|
||||
|
||||
`--mode general`(或 TUI 里 `/mode general`、WebUI 运行设置里的「工作模式」、`CST_MODE=general`)把默认口径
|
||||
从"代码助手"换成"通用助手":工作流从改代码/跑测试换成"先弄清要什么、再动手、给出处和不确定的地方",
|
||||
工具说明从"仓库"改成"文件与数据",并说明 `web_fetch` 可以拿外部资料;`/status` 第一行显示当前模式,
|
||||
切换后 history 里会留一条系统提示。默认仍是 `coding`,选择记在 `settings.json`;
|
||||
`--tools minimal`(论文口径)的提示词按字节冻结,不受模式影响。
|
||||
|
||||
### 剪贴板与文档附件
|
||||
|
||||
- **剪贴板**:`clipboard_read` / `clipboard_write` 两个工具(写剪贴板是不可逆发射,要确认);
|
||||
TUI 里 `/attach paste` 在剪贴板里没有图片时,把剪贴板的**文本**放进输入框(丢掉 CR 与控制字节,尊重输入长度上限)。
|
||||
- **附件**(`/attach <path>`、拖入终端、剪贴板图片都算):图片按原样存进会话目录,发给模型时是 image part;
|
||||
**PDF 在摄取时就抽出文本层**(零依赖解析器:Flate / ASCIIHex / ASCII85、对象流、CID 字体 ToUnicode、
|
||||
内联图像跳过),存的是文本、`mime` 仍是 `application/pdf`;其余文件必须是 UTF-8 文本。
|
||||
文档以 **text part 内联**发出,所以不受 `CST_NO_IMAGES` 或非视觉模型影响;图片照旧会被换成一行"未发送"说明。
|
||||
上限:单个附件 ≤16 MB(文档文本 ≤4 MiB),每条消息最多 8 个。
|
||||
加密 PDF、扫描件(无文本层)会被明确拒绝并说明原因;
|
||||
`view_image` 仍只收图片,文本/PDF 走 `/attach`。
|
||||
|
||||
### 权限、撤销与项目说明
|
||||
|
||||
- **审批模式**(Shift+Tab 循环切换,`/approval`,`--approval`,`CST_APPROVAL`):
|
||||
| 模式 | 写文件 | run_shell | web_fetch |
|
||||
|---|---|---|---|
|
||||
| `ask` | 询问 | 询问 | 询问 |
|
||||
| `auto`(默认) | 直接执行 | 有沙箱 → 在沙箱里直接执行;没有沙箱 → 只读白名单命令直接执行,其余询问 | 询问 |
|
||||
| `yolo` | 直接执行 | 直接执行(不进沙箱) | 直接执行 |
|
||||
|
||||
已保存的规则(`a` / `p`)优先:匹配的命令不进沙箱直接运行。
|
||||
`ask` / `auto` 的选择会记在 `settings.json`;`yolo` 只在本次运行有效。headless 行为不变(全部自动)。
|
||||
- **沙箱**(C 实现,`src/sandbox_exec.c`):
|
||||
- Linux:Landlock(内核 5.13+,直接 syscall,无需 libseccomp)。子进程只能写工作区、临时目录、`/dev/null`、`/dev/tty`;
|
||||
读、执行和联网不受限。
|
||||
- Linux 没有 Landlock 时(Android / Termux、WSL、老发行版;内核 5.5+,x86_64 / aarch64):seccomp user-notify
|
||||
(界面显示 `auto · seccomp`)。子进程的写类系统调用(带写标志的 open、创建 / 删除 / 改名 / 链接 / chmod 等)先交给
|
||||
agent 里的监督线程,按真实路径判断是否在工作区、临时目录或 `/dev/null` 等之内,不在就返回 `EACCES`。联网不受限。
|
||||
读、执行和已打开文件的写入不检查。它防的是误操作而不是恶意程序:检查之后内核会再读一次路径,多线程程序可以趁机换掉。
|
||||
探测还会确认能读直接子进程的内存;`ptrace_scope=2` 且无 `CAP_SYS_PTRACE` 时 seccomp 不会启用。
|
||||
比 shell 活得久的后台进程:桌面发行版开着 Yama(`ptrace_scope` ≥ 1)时它们的受检写入会被拒;命令结束后一律失败。
|
||||
- macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,不限制联网。
|
||||
- Windows 10 1903+:Low 完整性级别的受限令牌 + Job Object(界面显示 `auto · lowil`)。第一次在工作区运行时
|
||||
给它打上 Low 标签,之后子进程只能写工作区和沙箱临时目录,读不到凭据文件;不限制联网。
|
||||
`/sandbox` 查看、`/sandbox reset-labels` 撤销标签,详见 [docs/README-windows.md](docs/README-windows.md)。
|
||||
- 更老的内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。
|
||||
- `CST_SANDBOX=off` 强制关闭沙箱;`CST_SANDBOX=landlock` / `seccomp` / `lowil` 只用指定的那种。
|
||||
- 沙箱里的命令如果像是被拦了(`Permission denied` / `权限不够` / `Read-only file system` 等),会弹窗问
|
||||
“Rerun outside the sandbox?”:`y` 在沙箱外重跑一次 · `p` 保存命令前缀规则并重跑 · `n` 保留沙箱结果(模型会收到说明)。
|
||||
- **只读白名单**(无沙箱时 `auto` 用):`ls cat head tail wc grep rg find pwd echo which file stat du tree`
|
||||
和 `git status/diff/log/show/branch`;可以用 `|` `&&` `||` `;` 串联,但每段都必须在白名单里。
|
||||
`find -exec/-delete`、`rg --pre`、`git branch NAME`、`>` 重定向(`2>&1`、`>/dev/null` 除外)、`$(…)`、反引号、
|
||||
`<(…)`、`sudo`、`rm` 一律不算安全。
|
||||
- **确认弹窗**会显示命令或写入的 diff:
|
||||
`y` 允许一次 · `a` 本工具总是允许 · `p` 总是允许这个命令前缀(如 `make`、`git status`)· `n` / Esc 拒绝。
|
||||
`a` / `p` 写入 `<workspace>/.agent/permissions.json`,`/permissions` 查看,`/permissions reset` 清空。
|
||||
`sudo`、`rm`、解释器(`python3 -c …`)等危险前缀不会被建议成规则。
|
||||
- **撤销**:每轮写文件前自动快照到 `.agent/undo/`,`/undo` 恢复上一轮改动的全部文件(新建的文件会被删除)。
|
||||
- **AGENTS.md**:工作区根目录的 `AGENTS.md` 会注入 system prompt;`/init` 让 agent 自己生成一份。
|
||||
- **自动压缩**:上一轮 prompt 用到模型上下文 75% 时,下一轮开始前自动总结历史(`CST_AUTO_COMPACT=0` 关闭)。
|
||||
|
||||
### 扩展:Skills、插件、MCP、子 agent
|
||||
|
||||
`--tools minimal` 下这些都不加载(论文口径保持不变)。配置写法与示例见 [docs/plugins.md](docs/plugins.md)。
|
||||
|
||||
- **Skills**:`<dir>/SKILL.md`(frontmatter `name` + `description`),放在工作区 `.agent/skills/`、`.claude/skills/`
|
||||
或配置目录 `skills/`。system prompt 只放名字和一句描述,模型需要时调用 `skill(name)` 读全文;
|
||||
`/skills` 浏览,`/skill NAME 任务` 直接带着某个 skill 开始一轮。
|
||||
- **插件**:`plugins/<name>/plugin.json` 声明外部命令工具(参数以 JSON 写到 stdin,stdout 即结果,`perm` 决定审批,
|
||||
默认 60 s 超时),也可以附带 skills 目录和 MCP 服务器。
|
||||
- **MCP**:`mcp.json`(`{"mcpServers": {...}}`,与 Claude/Cursor 格式相同)里的 stdio 服务器在后台启动,
|
||||
工具注册为 `mcp__<server>__<tool>`,默认按网络工具审批(`autoApprove` 可放行)。`/mcp` 看状态,侧栏显示连接数。
|
||||
- **工作区信任**:工作区里的 `.agent/plugins/` 和 `.agent/mcp.json` 会执行命令,默认不加载;
|
||||
`/plugins trust` 后才生效(记在 `settings.json`)。配置目录(`~/.config/cst-agent/`)下的始终加载。
|
||||
- **子 agent**:`task(description, prompt, agent?)` 新开一个独立上下文跑完一轮,把最终报告交回主 agent。
|
||||
内置 `general`(除 task 外全部工具)、`explore`(只读,可并行)、`plan`(只读,输出计划);
|
||||
`.agent/agents/*.md` 或配置目录 `agents/*.md` 可自定义角色(frontmatter `name / description / model / tools /
|
||||
max_steps / readonly`,正文是角色说明)。子 agent 不能再开子 agent;Esc 会一并取消;它的审批弹窗标 `[角色名]`;
|
||||
文件改动并入当前轮,`/undo` 一起撤销。转录在 `.agent/sessions/<会话>/sub-N.jsonl`,`/agents` 查看。
|
||||
|
||||
### TUI 快捷键
|
||||
|
||||
默认 leader 是 **Ctrl+X**:先按 Ctrl+X,再按后面的字母。`/keys`(或 F1、Ctrl+X h)列出当前全部绑定。
|
||||
|
||||
| 按键 | 说明 |
|
||||
|---|---|
|
||||
| Enter | 发送;busy 时入队 |
|
||||
| Ctrl+N / Alt+Enter | 换行(粘贴的多行文本保持原样,不会被当成发送) |
|
||||
| Shift+Enter / Ctrl+Enter | 换行;需要支持 kitty 键盘协议的终端(kitty、WezTerm、foot、Ghostty、WHC Shell 等),启动时自动探测 |
|
||||
| ← → Home End Ctrl+A/E Ctrl+B/F Alt+B/F Ctrl/Alt+← → | 移动光标(按字 / 按词,支持中文宽字符) |
|
||||
| Ctrl+W / Alt+Backspace / Ctrl+Backspace / Ctrl+Delete | 向前删词 / 向后删词(Ctrl+Backspace 需要 kitty 键盘协议) |
|
||||
| Ctrl+U / Ctrl+K / Delete | 删到行首 / 删到行尾 / 删后一个字符 |
|
||||
| ↑ / ↓ | 提示历史(多行输入时先在行间移动) |
|
||||
| Tab | 补全 `/命令` 和 `@文件`;没有可补全时切换 Build / Plan(Plan 只开放只读工具) |
|
||||
| Shift+Tab | 循环切换审批模式 ask → auto → yolo |
|
||||
| Ctrl+P | 命令面板(输入过滤,↑↓ / 鼠标选择) |
|
||||
| Ctrl+O / Ctrl+R | 展开/折叠最近的 tool 输出 / 最近的 Thought |
|
||||
| Ctrl+T / Ctrl+L | 开关任务面板 / 重绘 |
|
||||
| PgUp / PgDn、Alt+↑ / Alt+↓、Ctrl+Home / Ctrl+End | 半页滚动、单行滚动、跳到开头 / 最新 |
|
||||
| Ctrl+X n / l | 新会话 / 会话列表 |
|
||||
| Ctrl+X m / p / e | 模型 / provider / 推理强度 |
|
||||
| Ctrl+X u / r / c | 撤销上一轮改动 / 重问 / 压缩上下文 |
|
||||
| Ctrl+X y / x | 复制上一条回复 / 导出记录 |
|
||||
| Ctrl+X t / d / s | 全部 Thought 默认展开或折叠 / 全部 tool 输出 / 侧栏 |
|
||||
| Ctrl+X a / k / g | 子 agent 面板 / skills / 插件 |
|
||||
| Ctrl+X h、F1 / Ctrl+X q | 快捷键列表 / 退出 |
|
||||
| 鼠标点击 | 展开/折叠 tool 卡片和 Thought、在输入框里定位光标、点模式行切换 Build/Plan、在选择框里选中 |
|
||||
| 鼠标拖动 / 双击 / 三击 | 在对话区选中文字(按字符 / 词 / 行),松开即通过 OSC 52 复制到剪贴板;拖到上下边缘会自动滚动。Shift+拖动或 `/mouse` 关闭后用终端原生选择 |
|
||||
| Esc | 取消当前 turn(不会退出) |
|
||||
| Ctrl+C 两次 / 空输入 Ctrl+D | 退出 |
|
||||
|
||||
Thought(推理过程)默认折叠成一行“Thought · 3.2s”,生成中显示实时计时;`/thinking on` 改为默认展开。
|
||||
|
||||
**自定义按键**:`~/.config/cst-agent/keybinds.json`,键是动作名(`/keys` 里能看到),值是一个或多个按键,
|
||||
`"none"` 解除绑定:
|
||||
|
||||
```json
|
||||
{
|
||||
"leader": "ctrl+x",
|
||||
"new_session": ["<leader> n", "f2"],
|
||||
"toggle_thought": "ctrl+g",
|
||||
"todo": "none"
|
||||
}
|
||||
```
|
||||
|
||||
按键写法:`ctrl+x`、`alt+b`、`shift+tab`、`f1`…`f12`、`pgup`、`home`、`<leader> n`。
|
||||
写错的条目会被跳过并在 `/keys` 里提示,其余照常生效。
|
||||
|
||||
### 斜杠命令
|
||||
|
||||
输入 `/` 弹出补全;唯一前缀即可(如 `/st` → `/status`)。标 * 的命令在模型运行中也能用。
|
||||
|
||||
| 命令 | 说明 |
|
||||
|---|---|
|
||||
| `/help` * | 命令与按键 |
|
||||
| `/new`、`/session [id]`、`/rename <标题>` *、`/delete [id]` | 新建 / 选择切换 / 命名 / 删除会话 |
|
||||
| `/provider [name]`、`/model [name]`、`/effort [level]` | 切换 provider(缺 key 时当场输入并用 `/models` 校验)、模型(列表优先显示 API 实际返回的模型,标 `(api)`)、推理强度;都会记住 |
|
||||
| `/key` | 输入或更换当前 provider 的 API key(遮罩输入并保存) |
|
||||
| `/protocol [name]` | 切换工具调用协议 |
|
||||
| `/mode [coding\|general]` | 工作模式(通用助手 / 代码助手口径) |
|
||||
| `/tools [on\|off NAME\|minimal\|default]` | 查看或开关工具 |
|
||||
| `/approval [ask\|auto\|yolo]` *、`/yolo` * | 审批模式;本次开关 yolo |
|
||||
| `/permissions [reset]` *、`/max-steps [N]` * | 权限规则、每轮步数 |
|
||||
| `/sidebar` *、`/mouse` * | 开关右侧边栏(≥120 列时显示)、开关鼠标 |
|
||||
| `/status` *、`/todo` * | 会话 / 模型 / 上下文占比 / 设置;任务清单 |
|
||||
| `/undo`、`/retry` | 撤销上一轮文件改动;丢弃上一条回复重问 |
|
||||
| `/diff [path]` * | `git status` + `git diff` |
|
||||
| `/compact` | 手动压缩上下文 |
|
||||
| `/export [file]` *、`/copy` * | 导出 Markdown 记录;复制上一条回复(OSC 52,需终端支持) |
|
||||
| `/attach [path\|paste\|list\|clear]` | 给下一条消息带附件:图片、PDF(自动取文本层)、文本文件;`paste` 优先取剪贴板图片,没有就把剪贴板文本放进输入框 |
|
||||
| `/web` * | 后台起一个 WebUI 进程(同 workspace、新会话,日志写到 `.agent/webui.log`) |
|
||||
| `/init` | 让 agent 写 `AGENTS.md` |
|
||||
| `/details` *、`/thinking [on\|off]`、`/keys` * | 展开全部 tool 输出;Thought 默认展开/折叠;快捷键列表 |
|
||||
| `/skills [reload\|list]`、`/skill <name> [任务]` | 浏览 skills;带着某个 skill 开始一轮 |
|
||||
| `/plugins [trust\|untrust\|reload]`、`/mcp [reload]` | 插件与工作区信任;MCP 服务器状态 |
|
||||
| `/agents` | 子 agent 角色与本会话的子 agent 记录(可查看转录) |
|
||||
| `/quit` * | 退出 |
|
||||
|
||||
### 协议
|
||||
|
||||
请求统一走 OpenAI 兼容 `POST …/chat/completions`(SSE)。工具调用的承载方式由 `--protocol` 决定:
|
||||
|
||||
| 协议 | 承载 | 何时执行 |
|
||||
|---|---|---|
|
||||
| `native-eos`(默认) | 原生 `tools[]` / SSE `tool_calls` | 整个流结束后(基线) |
|
||||
| `native-inc` | 同上 | 某个 index 的参数完整(下一个 index 开始或 finish)即执行 |
|
||||
| `ctl` | 正文中的 CTL 帧,长度前缀参数 | `end` 行一被确认即执行,流仍在继续 |
|
||||
| `ctl-heredoc` | 正文中的 CTL 帧,`<<TAG` 定界参数 | 同上 |
|
||||
| `xml` | MiniMax `<minimax:tool_call>` 文本 | 流结束后 |
|
||||
|
||||
文本协议(ctl / ctl-heredoc / xml)不发送 `tools[]`,工具结果以
|
||||
`result <id> exit=<n> <nbytes>` … `end` 帧放在下一条 user 消息里。
|
||||
同一会话可以随时切换协议:历史中的调用会按当前协议重新渲染。
|
||||
|
||||
## CTL tool language
|
||||
|
||||
```text
|
||||
call <id> <name>
|
||||
arg <key> <nbytes>
|
||||
<raw bytes exactly nbytes>
|
||||
arg <key> <<TAG
|
||||
<lines, verbatim>
|
||||
TAG
|
||||
arg <key> short inline value
|
||||
end
|
||||
```
|
||||
|
||||
Execution happens only on confirmed `end` (`CTL_EV_CALL_READY`); no prefix of
|
||||
a call is ever executable. See [docs/theory_tool_cst.md](docs/theory_tool_cst.md).
|
||||
|
||||
## Starvation experiment
|
||||
## 开发者
|
||||
|
||||
```bash
|
||||
make check
|
||||
./scripts/replay_starvation.sh
|
||||
./tests/test_replay_real # 真实 DeepSeek 录制流上的逐字节截断(实验 C)
|
||||
scripts/prompt_smoke.sh
|
||||
scripts/e2e_webui.sh
|
||||
```
|
||||
|
||||
CTL vs JSON-buffer 截断数字写入 [docs/eval_tool_cst.md](docs/eval_tool_cst.md) 实验 A / C;与 mini_calc e2e 一起作为 Tool-loop CST 附录素材(**不**替代 SD 主论文 Markdown→HTML 实验)。
|
||||
`icons/agent-motion/` 为 SVG 资产;`make hermit-font` 生成 `res/hermit-icons.ttf`(sbix 彩色位图,96 帧)与 `res/hermit-frames/`。安装脚本会把字体装进系统字体目录。
|
||||
|
||||
@@ -200,7 +200,7 @@ if(CST_APP_GUI)
|
||||
# the URI makes Qt register the types with relative URLs ("qmlRegisterType
|
||||
# requires absolute URLs" on every start).
|
||||
foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock
|
||||
NoteLine TodoPanel Composer ConsentDialog AskDialog SettingsDialog
|
||||
NoteLine TodoPanel Composer ConsentDialog AskDialog SettingsDialog TaskPanel
|
||||
ThemeIcon Theme ImageStrip)
|
||||
set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml)
|
||||
endforeach()
|
||||
@@ -218,6 +218,7 @@ if(CST_APP_GUI)
|
||||
qml/NoteLine.qml
|
||||
qml/TodoPanel.qml
|
||||
qml/Composer.qml
|
||||
qml/TaskPanel.qml
|
||||
qml/ConsentDialog.qml
|
||||
qml/AskDialog.qml
|
||||
qml/SettingsDialog.qml
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Deprecated: Qt front end
|
||||
|
||||
This directory is the **legacy Qt 6 / QML** Hermit GUI (`cst-agent-app`).
|
||||
|
||||
The supported desktop shell is **`apps/desktop`** (Tauri 2 + React), with a
|
||||
DeepSeek-Harness-inspired layout and an in-process C engine bridge.
|
||||
|
||||
Do not add features here. Once Tauri reaches feature parity, this tree will be
|
||||
moved to `archive/qt-app` or removed. Android APK scripts that target this tree
|
||||
are transitional.
|
||||
@@ -22,7 +22,7 @@ Rectangle {
|
||||
|
||||
function submit() {
|
||||
const t = input.text.trim()
|
||||
if (!t.length || running)
|
||||
if (!t.length)
|
||||
return
|
||||
input.text = ""
|
||||
submitted(t)
|
||||
@@ -142,7 +142,7 @@ Rectangle {
|
||||
Layout.preferredHeight: Math.min(root.compact ? 120 : 170,
|
||||
Math.max(56, contentHeight + 12))
|
||||
placeholderText: root.running
|
||||
? "the turn is running — Esc stops it"
|
||||
? "Send a correction now — Esc pauses execution"
|
||||
: (root.compact
|
||||
? "Ask for something…"
|
||||
: "Ask for something (Enter sends, Shift+Enter for a new line)")
|
||||
@@ -155,7 +155,7 @@ Rectangle {
|
||||
inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText |
|
||||
Qt.ImhNoAutoUppercase
|
||||
background: Rectangle { color: "transparent" }
|
||||
enabled: !root.running
|
||||
enabled: true
|
||||
|
||||
Keys.onPressed: function (ev) {
|
||||
if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) {
|
||||
@@ -206,9 +206,9 @@ Rectangle {
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
implicitWidth: root.compact ? 68 : 88
|
||||
implicitHeight: Theme.touchTarget + 4
|
||||
text: root.running ? "Stop" : "Send"
|
||||
text: root.running ? (input.text.trim().length ? "Redirect" : "Stop") : "Send"
|
||||
enabled: root.running || input.text.trim().length > 0
|
||||
onClicked: root.running ? agent.cancel() : root.submit()
|
||||
onClicked: root.running && !input.text.trim().length ? agent.cancel() : root.submit()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: !action.enabled ? Theme.surfaceAlt
|
||||
|
||||
@@ -24,6 +24,7 @@ ApplicationWindow {
|
||||
property string statusText: ""
|
||||
property var sessionsView: [] // the store, for the sidebar
|
||||
property bool navOpen: false // the sliding sidebar, compact only
|
||||
TaskPanel { id: taskPanel; anchors.centerIn: parent }
|
||||
property int sidebarWidth: 0 // 0 = follow the window width
|
||||
readonly property bool compact: width < Theme.compactBreakpoint
|
||||
readonly property int sidebarWide: sidebarWidth > 0
|
||||
@@ -318,6 +319,12 @@ ApplicationWindow {
|
||||
win.statusText = ""
|
||||
}
|
||||
}
|
||||
ToolButton {
|
||||
objectName: "taskPanelButton"
|
||||
text: "Tasks"
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: taskPanel.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
Dialog {
|
||||
id: root
|
||||
objectName: "taskControlDialog"
|
||||
title: "Task control and jobs"
|
||||
modal: true
|
||||
standardButtons: Dialog.Close
|
||||
width: Math.min(760, parent ? parent.width - 24 : 760)
|
||||
height: Math.min(640, parent ? parent.height - 24 : 640)
|
||||
property var state: ({})
|
||||
function refresh() {
|
||||
try { state = JSON.parse(agent.taskState()) } catch (e) { state = ({}) }
|
||||
}
|
||||
onOpened: { refresh(); config.text = agent.workerConfig() }
|
||||
Timer { interval: 500; repeat: true; running: root.visible; onTriggered: root.refresh() }
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
TabBar {
|
||||
id: tabs
|
||||
Layout.fillWidth: true
|
||||
TabButton { text: "Task / Jobs" }
|
||||
TabButton { text: "Models" }
|
||||
TabButton { text: "Integration" }
|
||||
}
|
||||
StackLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
currentIndex: tabs.currentIndex
|
||||
ColumnLayout {
|
||||
Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: (root.state.goal || "No active goal") }
|
||||
Label { text: "Revision " + (root.state.revision || 0) + " · " + (root.state.status || "idle") + " · " + (root.state.cancel_source || "") }
|
||||
RowLayout {
|
||||
CheckBox {
|
||||
id: readonly
|
||||
objectName: "taskReadOnly"
|
||||
text: "Read only"
|
||||
checked: !!root.state.read_only
|
||||
onClicked: agent.taskControl(checked, !direct.checked, !!root.state.paused)
|
||||
}
|
||||
CheckBox {
|
||||
id: direct
|
||||
objectName: "taskMainOnly"
|
||||
text: "Main agent only"
|
||||
checked: root.state.delegation === false
|
||||
onClicked: agent.taskControl(readonly.checked, !checked, !!root.state.paused)
|
||||
}
|
||||
Button { text: root.state.paused ? "Resume" : "Pause"; onClicked: agent.taskControl(readonly.checked, !direct.checked, !root.state.paused) }
|
||||
}
|
||||
ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
Repeater {
|
||||
model: root.state.jobs || []
|
||||
Frame {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
Label { text: modelData.job_id + " · " + modelData.kind + " · " + modelData.status }
|
||||
Label { Layout.fillWidth: true; text: modelData.resource || ""; elide: Text.ElideMiddle }
|
||||
Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: (modelData.output || "").slice(-1000) }
|
||||
RowLayout {
|
||||
Button { text: "Cancel"; enabled: modelData.status === "running" || modelData.status === "queued"; onClicked: agent.cancelJob(modelData.job_id, false) }
|
||||
Button { text: "Force stop"; visible: modelData.status === "running_protected"; onClicked: agent.cancelJob(modelData.job_id, true) }
|
||||
Button { text: "Review delta"; visible: (modelData.resource || "").indexOf("/.agent/v2/wt-") >= 0; onClicked: { worktree.text = modelData.resource; tabs.currentIndex = 2 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Role model profiles and budgets (provider credentials stay separate)"; wrapMode: Text.Wrap; Layout.fillWidth: true }
|
||||
ScrollView { Layout.fillWidth: true; Layout.fillHeight: true; TextArea { id: config; wrapMode: TextEdit.Wrap; selectByMouse: true } }
|
||||
Label { id: configStatus; Layout.fillWidth: true; wrapMode: Text.Wrap }
|
||||
Button { text: "Save model configuration"; enabled: !agent.running; onClicked: { const e = agent.saveWorkerConfig(config.text); configStatus.text = e.length ? e : "Saved" } }
|
||||
}
|
||||
ColumnLayout {
|
||||
TextField { id: worktree; Layout.fillWidth: true; placeholderText: "Worker worktree path" }
|
||||
RowLayout {
|
||||
Button { text: "Preview"; enabled: !agent.running; onClicked: delta.text = agent.worktreeDiff(worktree.text, false) }
|
||||
Button { text: "Integrate reviewed delta"; enabled: !agent.running; onClicked: delta.text = agent.worktreeDiff(worktree.text, true) }
|
||||
}
|
||||
ScrollView { Layout.fillWidth: true; Layout.fillHeight: true; TextArea { id: delta; readOnly: true; wrapMode: TextEdit.Wrap; selectByMouse: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@
|
||||
#include "cst_agent.h"
|
||||
#include "cst_attach.h"
|
||||
#include "cst_models.h"
|
||||
#include "cst_runtime.h"
|
||||
#include "cst_subagent.h"
|
||||
#include "cst_worktree.h"
|
||||
#include "cst_provider_switch.h"
|
||||
#include "cst_web_md.h"
|
||||
|
||||
@@ -140,7 +143,9 @@ bool AgentCore::open(const QString &workspace, const QString &provider,
|
||||
if (m_cfg.api_key[0] == 0 && m_cfg.provider[0]) {
|
||||
char key[512] = "";
|
||||
if (cst_creds_resolve(m_cfg.provider, key, sizeof(key), nullptr) != 0) {
|
||||
const char *env = getenv("CST_API_KEY");
|
||||
const char *env = getenv("HERMIT_API_KEY");
|
||||
if (!env || !env[0])
|
||||
env = getenv("CST_API_KEY");
|
||||
if (env && env[0])
|
||||
snprintf(key, sizeof(key), "%s", env);
|
||||
}
|
||||
@@ -233,8 +238,13 @@ QString firstLine(const QString &s) {
|
||||
} // namespace
|
||||
|
||||
void AgentCore::send(const QString &prompt) {
|
||||
if (!m_agent || m_running || prompt.trimmed().isEmpty())
|
||||
if (!m_agent || prompt.trimmed().isEmpty())
|
||||
return;
|
||||
if (m_running) {
|
||||
cst_agent_steer(m_agent, prompt.toUtf8().constData(), -1, -1);
|
||||
emit statusChanged(QStringLiteral("Applying latest instruction…"));
|
||||
return;
|
||||
}
|
||||
/* The prompt takes the pending images with it: the view shows them on the
|
||||
* message the composer just appended, before the turn can say anything. */
|
||||
const QString sending = pendingImages();
|
||||
@@ -255,6 +265,36 @@ void AgentCore::cancel() {
|
||||
if (m_agent)
|
||||
cst_agent_request_cancel(m_agent);
|
||||
}
|
||||
QString AgentCore::taskState() const {
|
||||
if (!m_agent) return QStringLiteral("{}");
|
||||
char *s = cst_runtime_json(cst_agent_config(m_agent)->runtime);
|
||||
QString result = QString::fromUtf8(s ? s : "{}"); free(s); return result;
|
||||
}
|
||||
void AgentCore::taskControl(bool ro, bool delegation, bool paused) {
|
||||
if (m_agent) cst_agent_control(m_agent, ro, delegation, paused);
|
||||
}
|
||||
void AgentCore::cancelJob(const QString &id, bool force) {
|
||||
if (m_agent) cst_job_cancel(cst_agent_config(m_agent)->runtime, id.toUtf8().constData(), force, "user");
|
||||
}
|
||||
QString AgentCore::workerConfig() const {
|
||||
if (!m_agent) return QStringLiteral("{}");
|
||||
char *s = cst_workers_config_get(cst_agent_config(m_agent)->workspace_root);
|
||||
QString result = QString::fromUtf8(s ? s : "{}"); free(s); return result;
|
||||
}
|
||||
QString AgentCore::saveWorkerConfig(const QString &json) {
|
||||
if (!m_agent || m_running) return QStringLiteral("Wait for the active task before changing model profiles.");
|
||||
char err[256];
|
||||
return cst_workers_config_set(cst_agent_config(m_agent)->workspace_root, json.toUtf8().constData(), err, sizeof(err)) ? QString::fromUtf8(err) : QString();
|
||||
}
|
||||
QString AgentCore::worktreeDiff(const QString &path, bool integrate) {
|
||||
if (!m_agent || m_running) return QStringLiteral("Wait for the active task before reviewing or integrating changes.");
|
||||
const auto *cfg = cst_agent_config(m_agent);
|
||||
if (cst_runtime_jobs_pending(cfg->runtime) || (integrate && !cst_runtime_check(cfg->runtime, 0, 1, 0)))
|
||||
return QStringLiteral("Integration is blocked by active jobs or task controls.");
|
||||
int code;
|
||||
char *s = cst_worktree_diff(cst_agent_config(m_agent)->workspace_root, path.toUtf8().constData(), integrate, &code);
|
||||
QString result = QString::fromUtf8(s ? s : "error"); free(s); return result;
|
||||
}
|
||||
|
||||
/* Qt suspends the application when the phone puts it away, and a desktop
|
||||
* window can be gone too (--uitest runs headless). Either way a consent
|
||||
|
||||
@@ -157,6 +157,12 @@ class AgentCore : public QObject {
|
||||
/* Ask a question; emits the streaming signals, then turnFinished(). */
|
||||
Q_INVOKABLE void send(const QString &prompt);
|
||||
Q_INVOKABLE void cancel();
|
||||
Q_INVOKABLE QString taskState() const;
|
||||
Q_INVOKABLE void taskControl(bool readOnly, bool delegation, bool paused);
|
||||
Q_INVOKABLE void cancelJob(const QString &id, bool force);
|
||||
Q_INVOKABLE QString workerConfig() const;
|
||||
Q_INVOKABLE QString saveWorkerConfig(const QString &json);
|
||||
Q_INVOKABLE QString worktreeDiff(const QString &path, bool integrate);
|
||||
|
||||
/* Consent: answers map to CST_PERM_*. */
|
||||
Q_INVOKABLE void answerPermission(int choice);
|
||||
|
||||
@@ -461,6 +461,22 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
clickMouse(win, QStringLiteral("planSwitch"));
|
||||
check(!core.planMode(), QStringLiteral("plan switch turns it off again"));
|
||||
|
||||
/* Task controls must reach the same runtime as the model and HTTP API. */
|
||||
clickMouse(win, QStringLiteral("taskPanelButton"));
|
||||
QTest::qWait(150);
|
||||
QObject *taskPanel = win->findChild<QObject *>(QStringLiteral("taskControlDialog"));
|
||||
check(taskPanel && taskPanel->property("visible").toBool(),
|
||||
QStringLiteral("Tasks opens the runtime control panel"));
|
||||
clickMouse(win, QStringLiteral("taskReadOnly"));
|
||||
clickMouse(win, QStringLiteral("taskMainOnly"));
|
||||
QJsonObject taskState = QJsonDocument::fromJson(core.taskState().toUtf8()).object();
|
||||
check(taskState.value(QStringLiteral("read_only")).toBool() &&
|
||||
!taskState.value(QStringLiteral("delegation")).toBool(),
|
||||
QStringLiteral("task panel enforces read-only and main-agent-only controls"));
|
||||
core.taskControl(false, true, false);
|
||||
if (taskPanel) QMetaObject::invokeMethod(taskPanel, "close");
|
||||
QTest::qWait(150);
|
||||
|
||||
/* --- keyboard: typing in the composer arms Send --- */
|
||||
QQuickItem *send = item(win, QStringLiteral("sendButton"));
|
||||
if (composer && send) {
|
||||
@@ -570,13 +586,14 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
}
|
||||
const QString rel =
|
||||
f.toMap().value(QStringLiteral("rel")).toString();
|
||||
if (rel.isEmpty() ||
|
||||
if (f.toMap().value(QStringLiteral("kind")).toString() == QLatin1String("img") || rel.isEmpty() ||
|
||||
!QFileInfo::exists(attachDir + QLatin1Char('/') + rel))
|
||||
continue;
|
||||
QList<QQuickItem *> hits;
|
||||
collectNamed(s, QStringLiteral("fileOpen%1").arg(i), &hits);
|
||||
if (!hits.isEmpty()) {
|
||||
openButton = hits.first();
|
||||
hits.clear();
|
||||
collectNamed(s, QStringLiteral("fileShare%1").arg(i), &hits);
|
||||
shareButton = hits.isEmpty() ? nullptr : hits.first();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -0,0 +1,57 @@
|
||||
# Hermit Desktop (Tauri 2)
|
||||
|
||||
Cross-platform shell for Hermit. Visual layout follows DeepSeek Harness
|
||||
(session rail · chat · tool timeline · composer); the agent engine is the
|
||||
in-process C core (`libcstcore`), not a Cordis/plugin runtime.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Tauri 2** + Rust host
|
||||
- **React 19** + Vite + TypeScript UI
|
||||
- FFI to `libcstcore` (same embed path as the legacy Qt app)
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Platform | Needs |
|
||||
|----------|--------|
|
||||
| All | Rust (stable), Node 20+, `pnpm` or `npm` |
|
||||
| Linux | `webkit2gtk-4.1`, `libgtk-3`, `librsvg`, `patchelf`, pkg-config, curl |
|
||||
| Windows | WebView2 runtime |
|
||||
| macOS | Xcode CLT |
|
||||
|
||||
Nix example:
|
||||
|
||||
```bash
|
||||
nix-shell -p rustc cargo nodejs_22 pkg-config webkitgtk_4_1 gtk3 librsvg openssl curl
|
||||
```
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
cd apps/desktop
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
Without a full engine link yet, the UI runs against the mock bridge
|
||||
(`HERMIT_DESKTOP_MOCK=1` or automatic when FFI is unavailable).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
Artifacts land in `src-tauri/target/release/bundle/`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
apps/desktop/
|
||||
src/ React UI (DSH-like)
|
||||
src-tauri/ Rust + Tauri + FFI
|
||||
README.md
|
||||
```
|
||||
|
||||
The legacy Qt front end under `/app` is frozen and will be archived once
|
||||
desktop feature parity lands.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hermit</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "hermit-desktop",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"@tauri-apps/plugin-shell": "^2.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"remark-gfm": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.1.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "hermit-desktop"
|
||||
version = "1.1.0"
|
||||
description = "Hermit desktop shell (Tauri 2)"
|
||||
authors = ["WHC"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "hermit_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
cc = "1.2"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "time"] }
|
||||
once_cell = "1"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
# Link libcstcore from the repo (requires curl + built archive).
|
||||
engine = []
|
||||
@@ -0,0 +1,29 @@
|
||||
fn main() {
|
||||
// Optional: link a prebuilt libcstcore when HERMIT_CORE_LIB is set.
|
||||
// Example:
|
||||
// HERMIT_CORE_LIB=/path/to/libcstcore.a HERMIT_CORE_INCLUDE=/path/to/include
|
||||
// cargo build --features engine
|
||||
if std::env::var("CARGO_FEATURE_ENGINE").is_ok() {
|
||||
if let Ok(lib) = std::env::var("HERMIT_CORE_LIB") {
|
||||
let path = std::path::PathBuf::from(&lib);
|
||||
if let Some(dir) = path.parent() {
|
||||
println!("cargo:rustc-link-search=native={}", dir.display());
|
||||
}
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("cstcore")
|
||||
.trim_start_matches("lib");
|
||||
println!("cargo:rustc-link-lib=static={}", stem);
|
||||
println!("cargo:rustc-link-lib=curl");
|
||||
println!("cargo:rustc-link-lib=pthread");
|
||||
println!("cargo:rustc-link-lib=m");
|
||||
println!("cargo:rerun-if-changed={}", lib);
|
||||
}
|
||||
if let Ok(inc) = std::env::var("HERMIT_CORE_INCLUDE") {
|
||||
println!("cargo:rerun-if-env-changed=HERMIT_CORE_INCLUDE");
|
||||
println!("cargo:rustc-env=HERMIT_CORE_INCLUDE={}", inc);
|
||||
}
|
||||
}
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default Hermit desktop capabilities",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default Hermit desktop capabilities","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open"]}}
|
||||
|
After Width: | Height: | Size: 682 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 233 B |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,173 @@
|
||||
//! Engine façade: mock by default; with `--features engine` + HERMIT_CORE_LIB
|
||||
//! this module will grow real FFI around `cst_agent.h` (AgentCore parity).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionInfo {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum ChatItem {
|
||||
#[serde(rename = "user")]
|
||||
User { id: String, text: String },
|
||||
#[serde(rename = "assistant")]
|
||||
Assistant { id: String, text: String },
|
||||
#[serde(rename = "thought")]
|
||||
Thought { id: String, text: String },
|
||||
#[serde(rename = "tool")]
|
||||
Tool {
|
||||
id: String,
|
||||
name: String,
|
||||
status: String,
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub approval: String,
|
||||
pub workspace: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum EngineEvent {
|
||||
#[serde(rename = "assistant_delta")]
|
||||
AssistantDelta { text: String },
|
||||
#[serde(rename = "thought_delta")]
|
||||
ThoughtDelta { text: String },
|
||||
#[serde(rename = "tool_start")]
|
||||
ToolStart {
|
||||
id: String,
|
||||
name: String,
|
||||
detail: String,
|
||||
},
|
||||
#[serde(rename = "tool_end")]
|
||||
ToolEnd {
|
||||
id: String,
|
||||
status: String,
|
||||
detail: String,
|
||||
},
|
||||
#[serde(rename = "consent")]
|
||||
Consent {
|
||||
req: ConsentRequest,
|
||||
},
|
||||
#[serde(rename = "turn_end")]
|
||||
TurnEnd { ok: bool },
|
||||
#[serde(rename = "error")]
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConsentRequest {
|
||||
pub id: String,
|
||||
pub tool: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
pub struct EngineState {
|
||||
pub settings: Settings,
|
||||
pub sessions: Vec<SessionInfo>,
|
||||
busy: bool,
|
||||
}
|
||||
|
||||
impl Default for EngineState {
|
||||
fn default() -> Self {
|
||||
let workspace = std::env::current_dir()
|
||||
.ok()
|
||||
.and_then(|p| p.to_str().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| ".".into());
|
||||
Self {
|
||||
settings: Settings {
|
||||
provider: "deepseek".into(),
|
||||
model: "deepseek-flash".into(),
|
||||
approval: "auto".into(),
|
||||
workspace,
|
||||
},
|
||||
sessions: vec![SessionInfo {
|
||||
id: "welcome".into(),
|
||||
title: "Welcome".into(),
|
||||
updated_at: chrono_now(),
|
||||
}],
|
||||
busy: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chrono_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub type SharedEngine = Mutex<EngineState>;
|
||||
|
||||
pub fn list_sessions(state: &SharedEngine) -> Vec<SessionInfo> {
|
||||
state.lock().unwrap().sessions.clone()
|
||||
}
|
||||
|
||||
pub fn new_session(state: &SharedEngine) -> SessionInfo {
|
||||
let mut g = state.lock().unwrap();
|
||||
let s = SessionInfo {
|
||||
id: format!("s{}", chrono_now()),
|
||||
title: "New session".into(),
|
||||
updated_at: chrono_now(),
|
||||
};
|
||||
g.sessions.insert(0, s.clone());
|
||||
s
|
||||
}
|
||||
|
||||
pub fn open_session(_state: &SharedEngine, _id: &str) -> Vec<ChatItem> {
|
||||
// Real FFI will replay transcript from cst_agent_store_*.
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn settings_get(state: &SharedEngine) -> Settings {
|
||||
state.lock().unwrap().settings.clone()
|
||||
}
|
||||
|
||||
pub async fn send_turn(app: AppHandle, state: &SharedEngine, text: String) {
|
||||
{
|
||||
let mut g = state.lock().unwrap();
|
||||
if g.busy {
|
||||
return;
|
||||
}
|
||||
g.busy = true;
|
||||
}
|
||||
|
||||
// Placeholder stream until libcstcore FFI is wired (feature = "engine").
|
||||
let _ = app.emit(
|
||||
"hermit://event",
|
||||
EngineEvent::ThoughtDelta {
|
||||
text: "Rust host received turn; FFI bridge pending full AgentCore port."
|
||||
.into(),
|
||||
},
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
let _ = app.emit(
|
||||
"hermit://event",
|
||||
EngineEvent::AssistantDelta {
|
||||
text: format!("Echo (host): {}", text),
|
||||
},
|
||||
);
|
||||
let _ = app.emit("hermit://event", EngineEvent::TurnEnd { ok: true });
|
||||
state.lock().unwrap().busy = false;
|
||||
}
|
||||
|
||||
pub fn cancel_turn(state: &SharedEngine) {
|
||||
state.lock().unwrap().busy = false;
|
||||
}
|
||||
|
||||
pub fn consent_answer(_id: &str, _answer: &str) {
|
||||
// Real path: wake the worker blocked in consent hook.
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Future FFI surface against `include/cst_agent.h` (AgentCore parity).
|
||||
//!
|
||||
//! Enable with:
|
||||
//! make -C ../.. libcstcore.a # or cmake cstcore
|
||||
//! HERMIT_CORE_LIB=$PWD/../../libcstcore.a \
|
||||
//! HERMIT_CORE_INCLUDE=$PWD/../../include \
|
||||
//! cargo build --features engine
|
||||
//!
|
||||
//! Mapping (Qt AgentCore → Tauri commands/events):
|
||||
//! open / send / cancel → session_* / send_turn / cancel_turn
|
||||
//! hooks.assistant_delta → hermit://event AssistantDelta
|
||||
//! hooks.tool_* → ToolStart / ToolEnd
|
||||
//! hooks.consent (blocking) → Consent event + consent_answer wakes waiter
|
||||
//! settings / creds → settings_get (+ later settings_set)
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
#[cfg(feature = "engine")]
|
||||
mod live {
|
||||
// Bindings land here (cbindgen or manual extern "C").
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "engine"))]
|
||||
pub fn engine_linked() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(feature = "engine")]
|
||||
pub fn engine_linked() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* Placeholder for future cbindgen / manual FFI against include/cst_agent.h.
|
||||
* Build with --features engine and HERMIT_CORE_LIB pointing at libcstcore.a.
|
||||
*/
|
||||
#pragma once
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Mirrors the subset AgentCore used; real symbols come from libcstcore. */
|
||||
typedef struct cst_agent cst_agent_t;
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,73 @@
|
||||
mod engine;
|
||||
mod ffi;
|
||||
|
||||
use engine::{SharedEngine, EngineState};
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
|
||||
#[tauri::command]
|
||||
fn session_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::SessionInfo> {
|
||||
engine::list_sessions(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn session_new(state: tauri::State<'_, SharedEngine>) -> engine::SessionInfo {
|
||||
engine::new_session(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn session_open(
|
||||
state: tauri::State<'_, SharedEngine>,
|
||||
id: String,
|
||||
) -> Vec<engine::ChatItem> {
|
||||
engine::open_session(&state, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn settings_get(state: tauri::State<'_, SharedEngine>) -> engine::Settings {
|
||||
engine::settings_get(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn send_turn(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, SharedEngine>,
|
||||
text: String,
|
||||
) -> Result<(), String> {
|
||||
engine::send_turn(app, &state, text).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cancel_turn(state: tauri::State<'_, SharedEngine>) {
|
||||
engine::cancel_turn(&state);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn consent_answer(id: String, answer: String) {
|
||||
engine::consent_answer(&id, &answer);
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(Mutex::new(EngineState::default()))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
session_list,
|
||||
session_new,
|
||||
session_open,
|
||||
settings_get,
|
||||
send_turn,
|
||||
cancel_turn,
|
||||
consent_answer
|
||||
])
|
||||
.setup(|app| {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.set_title("Hermit");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Hermit desktop");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
hermit_desktop_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hermit",
|
||||
"version": "1.1.0",
|
||||
"identifier": "xyz.whcself.hermit",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Hermit",
|
||||
"width": 1280,
|
||||
"height": 840,
|
||||
"minWidth": 880,
|
||||
"minHeight": 560,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": []
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "12.0"
|
||||
},
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Rail } from "@/components/Rail";
|
||||
import { Transcript } from "@/components/Transcript";
|
||||
import { Composer } from "@/components/Composer";
|
||||
import { ConsentModal } from "@/components/ConsentModal";
|
||||
import {
|
||||
answerConsent,
|
||||
cancelTurn,
|
||||
getSettings,
|
||||
listSessions,
|
||||
newSession,
|
||||
onEngineEvent,
|
||||
openSession,
|
||||
sendTurn,
|
||||
type ChatItem,
|
||||
type ConsentRequest,
|
||||
type SessionInfo,
|
||||
type Settings,
|
||||
} from "@/lib/bridge";
|
||||
import "@/styles/app.css";
|
||||
|
||||
export default function App() {
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [items, setItems] = useState<ChatItem[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
provider: "deepseek",
|
||||
model: "deepseek-flash",
|
||||
approval: "auto",
|
||||
workspace: ".",
|
||||
});
|
||||
const [consent, setConsent] = useState<ConsentRequest | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
setSessions(await listSessions());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
setSettings(await getSettings());
|
||||
await refreshSessions();
|
||||
})();
|
||||
}, [refreshSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
let un: (() => void) | undefined;
|
||||
void onEngineEvent((ev) => {
|
||||
if (ev.type === "assistant_delta") {
|
||||
setItems((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.kind === "assistant") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...last, text: last.text + ev.text },
|
||||
];
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ kind: "assistant", id: `a${Date.now()}`, text: ev.text },
|
||||
];
|
||||
});
|
||||
} else if (ev.type === "thought_delta") {
|
||||
setItems((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.kind === "thought") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...last, text: last.text + "\n" + ev.text },
|
||||
];
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ kind: "thought", id: `th${Date.now()}`, text: ev.text },
|
||||
];
|
||||
});
|
||||
} else if (ev.type === "tool_start") {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
kind: "tool",
|
||||
id: ev.id,
|
||||
name: ev.name,
|
||||
status: "running",
|
||||
detail: ev.detail,
|
||||
},
|
||||
]);
|
||||
} else if (ev.type === "tool_end") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.kind === "tool" && it.id === ev.id
|
||||
? { ...it, status: ev.status, detail: ev.detail || it.detail }
|
||||
: it,
|
||||
),
|
||||
);
|
||||
} else if (ev.type === "consent") {
|
||||
setConsent(ev.req);
|
||||
} else if (ev.type === "turn_end") {
|
||||
setBusy(false);
|
||||
} else if (ev.type === "error") {
|
||||
setBusy(false);
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
kind: "assistant",
|
||||
id: `err${Date.now()}`,
|
||||
text: `Error: ${ev.message}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}).then((u) => {
|
||||
un = u;
|
||||
});
|
||||
return () => un?.();
|
||||
}, []);
|
||||
|
||||
const open = async (id: string) => {
|
||||
setActiveId(id);
|
||||
setItems(await openSession(id));
|
||||
};
|
||||
|
||||
const onNew = async () => {
|
||||
const s = await newSession();
|
||||
await refreshSessions();
|
||||
setActiveId(s.id);
|
||||
setItems([]);
|
||||
};
|
||||
|
||||
const onSend = async () => {
|
||||
const text = draft.trim();
|
||||
if (!text || busy) return;
|
||||
setDraft("");
|
||||
setBusy(true);
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{ kind: "user", id: `u${Date.now()}`, text },
|
||||
]);
|
||||
await sendTurn(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Rail
|
||||
sessions={sessions}
|
||||
activeId={activeId}
|
||||
workspace={settings.workspace}
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
onNew={() => void onNew()}
|
||||
onOpen={(id) => void open(id)}
|
||||
onSettings={() => setShowSettings(true)}
|
||||
/>
|
||||
<section className="main">
|
||||
<header className="topbar">
|
||||
<h1>
|
||||
{sessions.find((s) => s.id === activeId)?.title ?? "New session"}
|
||||
</h1>
|
||||
<div className="chips">
|
||||
<span className="chip ok">engine ready</span>
|
||||
<span className="chip">
|
||||
<strong>{settings.model}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<Transcript items={items} />
|
||||
<Composer
|
||||
value={draft}
|
||||
busy={busy}
|
||||
settings={settings}
|
||||
onChange={setDraft}
|
||||
onSend={() => void onSend()}
|
||||
onStop={() => void cancelTurn()}
|
||||
/>
|
||||
</section>
|
||||
{consent ? (
|
||||
<ConsentModal
|
||||
req={consent}
|
||||
onAnswer={(a) => {
|
||||
void answerConsent(consent.id, a);
|
||||
setConsent(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{showSettings ? (
|
||||
<div className="consent" onClick={() => setShowSettings(false)}>
|
||||
<div
|
||||
className="consent-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3>Settings</h3>
|
||||
<pre>{JSON.stringify(settings, null, 2)}</pre>
|
||||
<div className="consent-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={() => setShowSettings(false)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Settings } from "@/lib/bridge";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
busy: boolean;
|
||||
settings: Settings;
|
||||
onChange: (v: string) => void;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
};
|
||||
|
||||
export function Composer({
|
||||
value,
|
||||
busy,
|
||||
settings,
|
||||
onChange,
|
||||
onSend,
|
||||
onStop,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.style.height = "0px";
|
||||
el.style.height = Math.min(200, el.scrollHeight) + "px";
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="composer-wrap">
|
||||
<div className="composer">
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={2}
|
||||
placeholder="Describe what you want to build, / commands, @ files…"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (!busy && value.trim()) onSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="composer-bar">
|
||||
<div className="composer-left">
|
||||
<span className="chip">
|
||||
<strong>{settings.model}</strong>
|
||||
</span>
|
||||
<span className="chip">
|
||||
{settings.provider} · {settings.approval}
|
||||
</span>
|
||||
</div>
|
||||
{busy ? (
|
||||
<button type="button" className="send stop" onClick={onStop}>
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="send"
|
||||
disabled={!value.trim()}
|
||||
onClick={onSend}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ConsentRequest } from "@/lib/bridge";
|
||||
|
||||
type Props = {
|
||||
req: ConsentRequest;
|
||||
onAnswer: (a: "deny" | "once" | "always_tool" | "always_prefix") => void;
|
||||
};
|
||||
|
||||
export function ConsentModal({ req, onAnswer }: Props) {
|
||||
return (
|
||||
<div className="consent">
|
||||
<div className="consent-card">
|
||||
<h3>Allow {req.tool}?</h3>
|
||||
<pre>{req.detail}</pre>
|
||||
<div className="consent-actions">
|
||||
<button type="button" onClick={() => onAnswer("deny")}>
|
||||
Deny
|
||||
</button>
|
||||
<button type="button" onClick={() => onAnswer("always_prefix")}>
|
||||
Always prefix
|
||||
</button>
|
||||
<button type="button" onClick={() => onAnswer("always_tool")}>
|
||||
Always tool
|
||||
</button>
|
||||
<button type="button" className="primary" onClick={() => onAnswer("once")}>
|
||||
Allow once
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { SessionInfo } from "@/lib/bridge";
|
||||
|
||||
type Props = {
|
||||
sessions: SessionInfo[];
|
||||
activeId: string | null;
|
||||
workspace: string;
|
||||
query: string;
|
||||
onQuery: (q: string) => void;
|
||||
onNew: () => void;
|
||||
onOpen: (id: string) => void;
|
||||
onSettings: () => void;
|
||||
};
|
||||
|
||||
export function Rail({
|
||||
sessions,
|
||||
activeId,
|
||||
workspace,
|
||||
query,
|
||||
onQuery,
|
||||
onNew,
|
||||
onOpen,
|
||||
onSettings,
|
||||
}: Props) {
|
||||
const filtered = sessions.filter(
|
||||
(s) => !query || s.title.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
return (
|
||||
<aside className="rail">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">H</div>
|
||||
<div>
|
||||
<div className="brand-name">Hermit</div>
|
||||
<div className="brand-sub">local coding agent</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="btn-new" onClick={onNew}>
|
||||
+ New session
|
||||
</button>
|
||||
|
||||
<div className="rail-sec">
|
||||
<span>Sessions</span>
|
||||
</div>
|
||||
<input
|
||||
className="sess-search"
|
||||
type="search"
|
||||
placeholder="Search sessions"
|
||||
value={query}
|
||||
onChange={(e) => onQuery(e.target.value)}
|
||||
/>
|
||||
<div className="sessions">
|
||||
{filtered.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={`sess${s.id === activeId ? " active" : ""}`}
|
||||
onClick={() => onOpen(s.id)}
|
||||
>
|
||||
<div className="sess-title">{s.title}</div>
|
||||
<div className="sess-meta">
|
||||
{new Date(s.updated_at).toLocaleString()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rail-foot">
|
||||
<div className="workspace" title={workspace}>
|
||||
{workspace}
|
||||
</div>
|
||||
<div className="rail-actions">
|
||||
<button type="button" className="icon-btn" onClick={onSettings}>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ChatItem } from "@/lib/bridge";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
type Props = { items: ChatItem[] };
|
||||
|
||||
export function Transcript({ items }: Props) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="transcript">
|
||||
<div className="empty">
|
||||
<h2>Meet Hermit</h2>
|
||||
<p>
|
||||
Local coding agent — read and edit your workspace, run commands with
|
||||
approval, keep sessions on disk. Describe a task to begin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="transcript">
|
||||
{items.map((it) => {
|
||||
if (it.kind === "user") {
|
||||
return (
|
||||
<div className="msg user" key={it.id}>
|
||||
<div className="msg-role">You</div>
|
||||
<div className="bubble">{it.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (it.kind === "assistant") {
|
||||
return (
|
||||
<div className="msg assistant" key={it.id}>
|
||||
<div className="msg-role">Hermit</div>
|
||||
<div className="bubble">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{it.text}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (it.kind === "thought") {
|
||||
return (
|
||||
<details className="thought" key={it.id}>
|
||||
<summary>Thought for a while</summary>
|
||||
<div className="thought-body">{it.text}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="tool-card" key={it.id}>
|
||||
<div className="tool-head">
|
||||
<span className="dot" />
|
||||
<span className="name">{it.name}</span>
|
||||
<span className="status">{it.status}</span>
|
||||
</div>
|
||||
{it.detail ? <div className="tool-body">{it.detail}</div> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/** IPC / mock bridge for Hermit desktop. */
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string;
|
||||
title: string;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
export type ConsentRequest = {
|
||||
id: string;
|
||||
tool: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type ChatItem =
|
||||
| { kind: "user"; id: string; text: string }
|
||||
| { kind: "assistant"; id: string; text: string }
|
||||
| { kind: "thought"; id: string; text: string }
|
||||
| {
|
||||
kind: "tool";
|
||||
id: string;
|
||||
name: string;
|
||||
status: "running" | "ok" | "denied" | "error";
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type EngineEvent =
|
||||
| { type: "assistant_delta"; text: string }
|
||||
| { type: "thought_delta"; text: string }
|
||||
| { type: "tool_start"; id: string; name: string; detail: string }
|
||||
| { type: "tool_end"; id: string; status: "ok" | "denied" | "error"; detail: string }
|
||||
| { type: "consent"; req: ConsentRequest }
|
||||
| { type: "turn_end"; ok: boolean }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
export type Settings = {
|
||||
provider: string;
|
||||
model: string;
|
||||
approval: "ask" | "auto" | "yolo";
|
||||
workspace: string;
|
||||
};
|
||||
|
||||
type Unlisten = () => void;
|
||||
|
||||
async function tryInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T | null> {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
return await invoke<T>(cmd, args);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: "s1",
|
||||
title: "Fix home TUI layout",
|
||||
updated_at: Date.now() - 3600_000,
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
title: "Ship hermit-update",
|
||||
updated_at: Date.now() - 86400_000,
|
||||
},
|
||||
];
|
||||
|
||||
let mockBusy = false;
|
||||
const mockListeners = new Set<(e: EngineEvent) => void>();
|
||||
|
||||
function emitMock(e: EngineEvent) {
|
||||
mockListeners.forEach((fn) => fn(e));
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<SessionInfo[]> {
|
||||
const real = await tryInvoke<SessionInfo[]>("session_list");
|
||||
return real ?? mockSessions;
|
||||
}
|
||||
|
||||
export async function newSession(): Promise<SessionInfo> {
|
||||
const real = await tryInvoke<SessionInfo>("session_new");
|
||||
if (real) return real;
|
||||
const s: SessionInfo = {
|
||||
id: `s${Date.now()}`,
|
||||
title: "New session",
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
mockSessions.unshift(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
export async function openSession(id: string): Promise<ChatItem[]> {
|
||||
const real = await tryInvoke<ChatItem[]>("session_open", { id });
|
||||
if (real) return real;
|
||||
if (id === "s1") {
|
||||
return [
|
||||
{ kind: "user", id: "u1", text: "右上角布局不对,帮我看看。" },
|
||||
{
|
||||
kind: "thought",
|
||||
id: "t1",
|
||||
text: "Sidebar starts at row 0 while topbar only covers the main pane…",
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
id: "tool1",
|
||||
name: "read_file",
|
||||
status: "ok",
|
||||
detail: "src/tui.c · draw_side / layout",
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
id: "a1",
|
||||
text: "顶栏只盖主区、侧栏从第 0 行铺满,右上角会空出一块。已把 topbar 拉满并把 seat deck 对齐到顶栏下方。",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function getSettings(): Promise<Settings> {
|
||||
const real = await tryInvoke<Settings>("settings_get");
|
||||
return (
|
||||
real ?? {
|
||||
provider: "deepseek",
|
||||
model: "deepseek-flash",
|
||||
approval: "auto",
|
||||
workspace: "~/hermit",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendTurn(text: string): Promise<void> {
|
||||
const real = await tryInvoke<void>("send_turn", { text });
|
||||
if (real !== null) return;
|
||||
if (mockBusy) return;
|
||||
mockBusy = true;
|
||||
emitMock({ type: "thought_delta", text: "Planning a short reply…" });
|
||||
await delay(400);
|
||||
emitMock({
|
||||
type: "tool_start",
|
||||
id: "m1",
|
||||
name: "shell",
|
||||
detail: "hermit --version",
|
||||
});
|
||||
await delay(500);
|
||||
emitMock({
|
||||
type: "tool_end",
|
||||
id: "m1",
|
||||
status: "ok",
|
||||
detail: "hermit 1.1.0",
|
||||
});
|
||||
const reply =
|
||||
"这是桌面壳的本地演示流(未链上引擎时)。接上 libcstcore 后,这里会变成真实 turn。";
|
||||
for (const ch of reply) {
|
||||
emitMock({ type: "assistant_delta", text: ch });
|
||||
await delay(12);
|
||||
}
|
||||
emitMock({ type: "turn_end", ok: true });
|
||||
mockBusy = false;
|
||||
}
|
||||
|
||||
export async function cancelTurn(): Promise<void> {
|
||||
await tryInvoke("cancel_turn");
|
||||
mockBusy = false;
|
||||
emitMock({ type: "turn_end", ok: false });
|
||||
}
|
||||
|
||||
export async function answerConsent(
|
||||
id: string,
|
||||
answer: "deny" | "once" | "always_tool" | "always_prefix",
|
||||
): Promise<void> {
|
||||
await tryInvoke("consent_answer", { id, answer });
|
||||
}
|
||||
|
||||
export async function onEngineEvent(fn: (e: EngineEvent) => void): Promise<Unlisten> {
|
||||
try {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
const un = await listen<EngineEvent>("hermit://event", (ev) => fn(ev.payload));
|
||||
return () => {
|
||||
un();
|
||||
};
|
||||
} catch {
|
||||
mockListeners.add(fn);
|
||||
return () => {
|
||||
mockListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,425 @@
|
||||
@import "./theme.css";
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: var(--rail-w) 1fr;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* —— left rail (DSH-like) —— */
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px 12px;
|
||||
background: var(--bg-elev);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(145deg, var(--brand), var(--brand-dim));
|
||||
box-shadow: inset 0 0 0 1px rgba(169, 217, 215, 0.25);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent-text);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
.brand-name {
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.brand-sub {
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.btn-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.btn-new:hover {
|
||||
background: #2f94ab;
|
||||
}
|
||||
|
||||
.rail-sec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 6px 0;
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.sess-search {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
outline: none;
|
||||
}
|
||||
.sess-search:focus {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.sessions {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.sess {
|
||||
text-align: left;
|
||||
padding: 10px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.sess:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
.sess.active {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
.sess-title {
|
||||
font-weight: 550;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sess-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rail-foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.workspace {
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-family: var(--mono);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rail-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.icon-btn {
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* —— main column —— */
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px var(--pad);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elev);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.chip strong {
|
||||
color: var(--text);
|
||||
font-weight: 550;
|
||||
}
|
||||
.chip.ok {
|
||||
border-color: rgba(61, 154, 106, 0.35);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.transcript {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px var(--pad) 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: auto;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.empty h2 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
}
|
||||
.empty p {
|
||||
margin: 0;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.msg {
|
||||
max-width: 820px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.msg.user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.msg-role {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.bubble {
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.msg.user .bubble {
|
||||
background: linear-gradient(160deg, var(--brand-dim), #1a4a56);
|
||||
border-color: transparent;
|
||||
}
|
||||
.msg.assistant .bubble {
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
|
||||
.thought,
|
||||
.tool-card {
|
||||
max-width: 820px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elev);
|
||||
overflow: hidden;
|
||||
}
|
||||
.thought summary,
|
||||
.tool-head {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.thought summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.thought[open] summary {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.thought-body,
|
||||
.tool-body {
|
||||
padding: 10px 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.tool-head .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--crab);
|
||||
}
|
||||
.tool-head .name {
|
||||
color: var(--text);
|
||||
font-weight: 550;
|
||||
}
|
||||
.tool-head .status {
|
||||
margin-left: auto;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.composer-wrap {
|
||||
padding: 10px var(--pad) 16px;
|
||||
}
|
||||
.composer {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
padding: 10px 12px 8px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.composer textarea {
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
max-height: 200px;
|
||||
resize: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
.composer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.composer-left {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.send {
|
||||
height: 34px;
|
||||
min-width: 72px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--crab);
|
||||
color: #1a100c;
|
||||
font-weight: 650;
|
||||
}
|
||||
.send:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
.send.stop {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.consent {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 40;
|
||||
}
|
||||
.consent-card {
|
||||
width: min(480px, calc(100% - 32px));
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.consent-card h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.consent-card pre {
|
||||
margin: 0 0 14px;
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
}
|
||||
.consent-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.consent-actions button {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.consent-actions .primary {
|
||||
background: var(--brand);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.rail {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
:root {
|
||||
/* Hermit brand + DSH-like rhythm */
|
||||
--brand: #277f94;
|
||||
--brand-dim: #1d5f6e;
|
||||
--crab: #f68042;
|
||||
--eye: #9957ad;
|
||||
--ok: #3d9a6a;
|
||||
--warn: #e9ba47;
|
||||
--danger: #c45c4a;
|
||||
|
||||
--bg: #0f1114;
|
||||
--bg-elev: #161a1f;
|
||||
--bg-card: #1b2128;
|
||||
--bg-hover: #222933;
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
--border-strong: rgba(255, 255, 255, 0.12);
|
||||
--text: #e8ecf0;
|
||||
--text-muted: #8b949e;
|
||||
--text-faint: #5c6570;
|
||||
--accent-text: #a9d9d7;
|
||||
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--pad: 16px;
|
||||
--rail-w: 260px;
|
||||
--font: "IBM Plex Sans", "Segoe UI", "Noto Sans SC", system-ui, sans-serif;
|
||||
--mono: "IBM Plex Mono", "JetBrains Mono", ui-monospace, monospace;
|
||||
--shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/Composer.tsx","./src/components/ConsentModal.tsx","./src/components/Rail.tsx","./src/components/Transcript.tsx","./src/lib/bridge.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "src") },
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
|
||||
watch: { ignored: ["**/src-tauri/**"] },
|
||||
},
|
||||
envPrefix: ["VITE_", "TAURI_"],
|
||||
build: {
|
||||
target: process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13",
|
||||
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
|
||||
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
||||
},
|
||||
});
|
||||
@@ -1,13 +1,8 @@
|
||||
# NixOS and other nix users: the desktop front end (Qt Quick) with the engine
|
||||
# it embeds, built and installed the nix way.
|
||||
# NixOS and other nix users: legacy Qt Quick front end (frozen; Tauri app is
|
||||
# apps/desktop). CLI remains scripts/bootstrap_linux.sh.
|
||||
#
|
||||
# nix-build # ./result/bin/cst-agent-app, run it in place
|
||||
# nix-env -f . -i cst-agent # into the user profile: PATH, menu entry, icon
|
||||
#
|
||||
# The Qt apps hook is what makes the installed binary find Qt's plugins and
|
||||
# QML modules outside a nix-shell (this is also what keeps them out of
|
||||
# nix-collect-garbage's reach). Only the window is installed: the CLI has its
|
||||
# own installer, scripts/bootstrap_linux.sh.
|
||||
# nix-build # ./result/bin/hermit-app (Qt, transitional)
|
||||
# nix-env -f . -i hermit
|
||||
{ pkgs ? import <nixpkgs> { } }:
|
||||
|
||||
let
|
||||
@@ -21,7 +16,7 @@ let
|
||||
builtins.head (builtins.match "#define ${re} ([0-9]+)" hit);
|
||||
in
|
||||
pkgs.stdenv.mkDerivation {
|
||||
pname = "cst-agent";
|
||||
pname = "hermit";
|
||||
version =
|
||||
"${vLine "CST_VERSION_MAJOR"}.${vLine "CST_VERSION_MINOR"}.${vLine "CST_VERSION_PATCH"}";
|
||||
|
||||
@@ -91,8 +86,8 @@ pkgs.stdenv.mkDerivation {
|
||||
|
||||
meta = {
|
||||
description =
|
||||
"Local coding agent: Qt Quick front end with the engine in-process";
|
||||
homepage = "https://git.whcself.xyz/WHC/CST-agent";
|
||||
"Hermit: local coding agent (legacy Qt Quick front end; prefer apps/desktop)";
|
||||
homepage = "https://git.whcself.xyz/WHC/Hermit";
|
||||
platforms = pkgs.lib.platforms.linux;
|
||||
mainProgram = "cst-agent-app";
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Hermit 文档索引
|
||||
|
||||
规范仓库:[WHC/Hermit](https://git.whcself.xyz/WHC/Hermit)。从旧名迁移见根目录 [MIGRATION.md](../MIGRATION.md)。
|
||||
|
||||
二进制:`hermit`(兼容软链 `cst-agent`)。配置目录:`~/.config/hermit`(若无则回退 `~/.config/cst-agent`)。
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [env_openai.md](env_openai.md) | Provider、模型、API Key、环境变量(`HERMIT_*` / `CST_*`) |
|
||||
| [parliament.md](parliament.md) | 多设备议会 |
|
||||
| [workers.md](workers.md) | 后台任务、worker、job 完成报告 |
|
||||
| [webui.md](webui.md) | 内置 HTTP + SSE WebUI |
|
||||
| [plugins.md](plugins.md) | Skills、插件、MCP |
|
||||
| [compat.md](compat.md) | 平台兼容 |
|
||||
| [README-windows.md](README-windows.md) | Windows |
|
||||
| [android.md](android.md) | Android / APK |
|
||||
| [archive/](archive/) | 历史设计笔记(非产品文档) |
|
||||
@@ -0,0 +1,10 @@
|
||||
# 归档
|
||||
|
||||
历史设计笔记与评测草稿,**不是**面向用户的产品文档,也不再作为论文素材维护。
|
||||
|
||||
| 文件 | 说明 |
|
||||
|---|---|
|
||||
| `theory_tool_cst.md` | 早期工具调用确认流设计笔记 |
|
||||
| `eval_tool_cst.md` | 早期协议/夹具评测草稿 |
|
||||
|
||||
日常使用请看仓库根目录 [README.md](../../README.md) 与同级其它 `docs/*.md`。
|
||||
@@ -1,9 +1,8 @@
|
||||
# Tool-loop CST 评测笔记(反哺论文素材)
|
||||
|
||||
> 本文是 **CST-Agent** 侧的评测/案例记录,供写入 SD 主论文附录或独立 TR 时粘贴。
|
||||
> **不**替代 [`SD/paper/sections/eval_zh.tex`](../../SD/paper/sections/eval_zh.tex) 中 Markdown→HTML 主实验。
|
||||
> **归档。** 早期评测草稿,已停止维护;请勿当作当前产品说明。
|
||||
|
||||
理论锚点见 [theory_tool_cst.md](theory_tool_cst.md)。
|
||||
理论锚点见同目录 [theory_tool_cst.md](theory_tool_cst.md)。
|
||||
|
||||
## 命题分工
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Tool-loop CST:确认前沿推广到工具调用
|
||||
|
||||
> **归档。** 早期设计笔记,已停止维护;请勿当作当前产品说明。见 [README.md](README.md)。
|
||||
|
||||
## 命题
|
||||
|
||||
在 agent 工具环中,对模型输出做**确认流式转导**:只在 \(O_{\mathrm{conf}}\) 内不可逆地发射「可执行工具调用 / 可应用补丁 / 可提交会话状态」。相对「拼到合法 JSON 再执行」的缓冲基线,这能降低注意力饥渴导致的残缺工具帧(未闭合括号、半截 arguments)被过早执行或整轮失败的问题。
|
||||
@@ -22,8 +22,8 @@ adds Homebrew's keg-only `ncurses`/`curl` to `PKG_CONFIG_PATH`; without
|
||||
Homebrew it falls back to the system `-lncurses` (wide-character capable).
|
||||
|
||||
Verified so far: NixOS x86_64 with gcc, clang and TinyCC (`-Werror` except
|
||||
tcc, `make check`, `make asan`, ctest). CI (`.gitea/workflows/ci.yml`) checks
|
||||
with TinyCC on Debian and builds the static musl releases for x86_64, x86 and
|
||||
tcc, `make check`, `make asan`, ctest). CI (`.gitea/workflows/ci.yml`)
|
||||
checks with TinyCC and builds the static musl releases for x86_64, x86 and
|
||||
aarch64 with gcc `-Werror` (`make check` on both x86 targets, a qemu smoke
|
||||
test on aarch64). macOS and WSL are checked by hand with the list below.
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Hermit Desktop (Tauri)
|
||||
|
||||
Canonical GUI: [`apps/desktop`](../apps/desktop/). Qt under [`app/`](../app/) is frozen ([DEPRECATED](../app/DEPRECATED.md)).
|
||||
|
||||
## Shape
|
||||
|
||||
- **Shell**: Tauri 2 (Linux / Windows / macOS)
|
||||
- **UI**: React 19 — session rail, chat, tool cards, composer (DeepSeek Harness–like IA)
|
||||
- **Engine**: in-process `libcstcore` via Rust FFI (feature `engine`); without it the host uses a mock / echo path so the UI is developable
|
||||
|
||||
## Dev
|
||||
|
||||
```bash
|
||||
nix-shell -p rustc cargo nodejs_22 pkg-config webkitgtk_4_1 gtk3 librsvg openssl curl
|
||||
cd apps/desktop && npm install && npm run tauri dev
|
||||
```
|
||||
|
||||
## Product env
|
||||
|
||||
`HERMIT_*` preferred, `CST_*` accepted (see `hermit_getenv`). Config dir `~/.config/hermit`.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Parliament(议会)快速上手
|
||||
|
||||
密码串派生 Realm → 登录夺主席 / 加入席位 → 常驻服务密封同步共享上下文。
|
||||
主席是用户入口;成员席位在本机干活并用 `parliament_note` 通报进度。
|
||||
|
||||
## 一次性初始化
|
||||
|
||||
```bash
|
||||
# 交互输入密码,或事先 export CST_REALM_PASSWORD=…
|
||||
cst-agent --realm-password-init
|
||||
```
|
||||
|
||||
磁盘只存 salt + 公钥,**不存私钥**。
|
||||
|
||||
## 日常:登录夺主席,或加入席位
|
||||
|
||||
```bash
|
||||
export CST_INSTANCE_ID=laptop-a # 可选;默认用本机 hostname
|
||||
cst-agent --login --realm-label "thinkpad"
|
||||
# 其他机器(同 salt/公钥 + 密码):
|
||||
cst-agent --join --instance phone-b --realm-label "pixel"
|
||||
# TUI: /realm login(夺主席)或 /realm join(不抢主席)
|
||||
```
|
||||
|
||||
跨网段(如 Tailscale)多播发现不可用时,在 Realm 目录放 `peers.json`:
|
||||
|
||||
```json
|
||||
{"peers":[{"host":"100.64.0.13","port":9100,"iid":"labpc","name":"labpc"}]}
|
||||
```
|
||||
|
||||
效果:
|
||||
|
||||
1. 用密码解锁 Realm;`--login` / `/realm login` 使本机成为**唯一主席**
|
||||
2. `--join` / `/realm join` 只登记席位票:主席空缺时才夺椅,否则以 **member** 身份加入
|
||||
3. 写入本地登录票(24h;成员票在主席易主后仍有效,除非本席被剥夺主席)
|
||||
4. 拉起后台议会服务(发现 + 密封通道按 revision 同步 `parliament.context`;
|
||||
Unix 下 accept 独立线程,dial 失败会指数退避,避免饿死入站)
|
||||
|
||||
查看状态:
|
||||
|
||||
```bash
|
||||
cst-agent # TUI: /status 或 /realm
|
||||
# /realm login|join|stop
|
||||
cst-agent --service-stop
|
||||
```
|
||||
|
||||
### 常驻服务(推荐)
|
||||
|
||||
登录后会 fork 后台席位进程;也可用 systemd --user 托管前台循环(崩溃自动拉起):
|
||||
|
||||
```bash
|
||||
# 安装脚本附带的辅助命令
|
||||
cst-agent-service install-unit
|
||||
# 在 ~/.config/cst-agent/parliament.env 写入:
|
||||
# CST_REALM_PASSWORD=…
|
||||
# CST_INSTANCE_ID=my-laptop
|
||||
systemctl --user enable --now cst-agent-parliament.service
|
||||
cst-agent-service status
|
||||
```
|
||||
|
||||
或手动前台:`CST_REALM_PASSWORD=… cst-agent --service-fg`。
|
||||
|
||||
## 单机默认(solo)
|
||||
|
||||
未设置 `collaboration` 时,**即使存在密码 Realm,默认也是单机 solo**:`task` /
|
||||
`worker_*` / runtime jobs 可用,不强制登录闸。密码 Realm 仅保管密钥,不再自动开议会。
|
||||
|
||||
要进入议会模式,任选其一:
|
||||
|
||||
- `CST_COLLABORATION=parliament` 或 `settings.json` 里 `"collaboration": "parliament"`
|
||||
- 已有 open 状态的 parliament meta(例如刚 `--login` 过)
|
||||
|
||||
显式议会模式下打开 agent 才要求登录(TTY 下可当场输入密码)。强制恢复本机 worker:
|
||||
`CST_COLLABORATION=legacy_workers`。
|
||||
|
||||
## 议员协作协议
|
||||
|
||||
席位通过共享 `parliament.context` 协作(不是进程内 `task`/`worker_*`):
|
||||
|
||||
1. 开工前阅读 Environment 里的 Shared parliament context
|
||||
2. 用本机工具完成分配切片
|
||||
3. 跨席相关的进度/议程/结果用工具 **`parliament_note`** 发布(`kind=status|agenda|result|handover`)
|
||||
4. 服务在写入后尽快 dial peers;回合内若 revision 变化,会注入 `[cst] Parliament shared context updated`
|
||||
|
||||
显式 `"collaboration": "parliament"`(或 env)时:
|
||||
|
||||
- **硬禁用** `task` / `worker_*` / runtime 委托工具
|
||||
- 提供 `parliament_note`;提示词说明 chair / member 角色合同
|
||||
|
||||
## 换项目(清场)
|
||||
|
||||
```bash
|
||||
cst-agent --new-project my-app
|
||||
```
|
||||
|
||||
清空共享上下文、撤销主席与登录票,并停止本机服务。Realm 密钥保留。
|
||||
|
||||
## 传输安全
|
||||
|
||||
Peer 在 Realm 证书互鉴后做 **临时 X25519 密钥交换**,后续议会上下文同步走
|
||||
**XChaCha20-Poly1305**(Monocypher,无需 OpenSSL)。握手前的发现/证书交换仍是明文元数据。
|
||||
|
||||
## Docker 冒烟
|
||||
|
||||
```bash
|
||||
bash scripts/docker_parliament_test.sh
|
||||
```
|
||||
|
||||
Alpine 内构建,chair↔member 握手 + 密封通道同步(`sealed=1` / `peer_sync`)。
|
||||
|
||||
## 相关标志
|
||||
|
||||
| 标志 | 作用 |
|
||||
|------|------|
|
||||
| `--realm-password-init` | 创建密码 Realm |
|
||||
| `--login` | 登录 + 夺主席 + 启服务 |
|
||||
| `--join` | 加入席位(不抢主席)+ 启服务 |
|
||||
| `--service` / `--service-fg` | 仅启服务 |
|
||||
| `--service-stop` | 停服务 |
|
||||
| `--new-project ID` | 清场换项目 |
|
||||
| `--peer-port N` | 登录/服务用的 TCP 端口(默认 9100) |
|
||||
| `--instance ID` | 实例 id(也可用 `CST_INSTANCE_ID`;默认 hostname) |
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 148 KiB |
@@ -0,0 +1,125 @@
|
||||
# 多模型 worker 与编排模式
|
||||
|
||||
主 agent 使用强模型进行设计与验收,子 agent 按角色绑定较便宜的模型。
|
||||
用户配置目录的 `workers.json` 可设置所有项目的默认配置;存在工作区
|
||||
`.agent/workers.json` 时,以工作区的完整配置为准,不合并两份配置。
|
||||
在工作区创建 `.agent/workers.json`,或者通过 TUI `/agents config <JSON>`、
|
||||
WebUI 扩展面板的「模型与 worker 配置」保存同样的配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"orchestrate": true,
|
||||
"max_steps": 20,
|
||||
"max_tasks": 8,
|
||||
"max_requests": 100,
|
||||
"profiles": {
|
||||
"worker": {
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-flash",
|
||||
"effort": "low"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"general": "worker",
|
||||
"explore": "worker",
|
||||
"plan": "worker"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
provider 必须已注册,使用其独立保存的凭据或 provider 的环境变量。
|
||||
档案支持 `temperature`、`top_p`、`max_tokens`。不要在此文件中保存 API Key。
|
||||
自定义角色 Markdown frontmatter 也可指定 `profile: worker`;JSON 中的角色绑定优先。
|
||||
未绑定的角色继承主 agent;旧 `model` frontmatter 仍在原 provider 下生效。
|
||||
|
||||
CLI `--orchestrate` / `--no-orchestrate` 可以覆盖工作区设置。开启后主 agent
|
||||
优先委派适合拆分的任务,但保留获授权的写文件和 shell 工具;简单操作可以直接执行。
|
||||
规划模式和只读限制继续约束子 agent。旧 `task` 的只读调用可并行,可写调用串行。
|
||||
|
||||
## V2:任务控制与异步执行
|
||||
|
||||
默认工具配置增加 `worker_start`:`kind=read` 路由到 `explore` 角色;
|
||||
`kind=edit` 路由到 `general`,必须提供 `files` 路径数组,在独立 Git worktree 中编辑;
|
||||
`kind=external` 使用受保护的串行资源。模型仍由上述 profiles/roles 配置决定,
|
||||
例如 explore 绑定 DS Flash、general 绑定 DS Pro,主 agent 使用 Kimi Code。
|
||||
旧 `task`、worker 续接和预算配置继续兼容。
|
||||
|
||||
异步调用返回 `job_id`。`job_read` 查看状态,`job_wait` 最多等待 30 秒;
|
||||
主循环在未完成的作业上等待,不通过反复请求模型轮询。
|
||||
主 agent 的默认 `run_shell` 在 250ms 内未结束时也返回作业状态。
|
||||
`effect=workspace` 表示允许安全中断的工作区命令;未声明的外部命令默认受保护,
|
||||
涉及 fastboot/adb/flash 的命令始终按受保护作业处理。这是调度约定,不是安全认证。
|
||||
|
||||
新消息在执行中发送时会作为纠正指令:任务版本递增,旧版本后续工具调用被拒绝,
|
||||
可中断作业收到取消请求;已完成的副作用不会回滚。受保护外部操作不因普通取消被杀死,
|
||||
只有用户明确强制停止或程序关闭时才强制中断。不要把强制停止用于正在刷写的设备。
|
||||
状态明确记录取消来源,不能把 instruction_changed/user 等同于 timeout。
|
||||
纠正后只等待当前任务版本的作业;旧版本受保护作业仍在后台时,任务报告 partial,
|
||||
不把新指令卡在旧操作上,也不谎报旧操作已完成。
|
||||
|
||||
TUI:`/task state`、`/task readonly`、`/task write`、`/task direct`(禁止委派)、
|
||||
`/task auto`、`/task pause`、`/task resume`、`/task cancel JOB_ID`、`/task force JOB_ID`。
|
||||
暂停/恢复是权限控制,不会重放操作;恢复后发送下一条指令继续。
|
||||
WebUI `/task` 打开任务面板;Qt 桌面/Android 共用 Tasks 面板。
|
||||
面板可查看作业输出、切换约束、取消作业、预览和集成编辑结果;Qt 还提供 worker JSON 配置。
|
||||
HTTP 对应 `GET/POST /api/task`、`POST /api/jobs/cancel`、`POST /api/worktree`。
|
||||
|
||||
编辑 worker 的 `worker_diff` 预览增量,`worker_integrate` 执行三方合并。
|
||||
基线包含用户已有 tracked 修改及显式作用域内的 untracked 输入,使用私有 index,
|
||||
不会暂存、提交或重置用户分支。集成冲突时原文件和用户 index 不变,worktree/补丁保留。
|
||||
非 Git 根目录使用直接执行或兼容 task。worktree 不是恶意 shell 的完整安全边界;
|
||||
可用时编辑 shell 使用系统 sandbox,集成时再检查 tracked 修改范围。
|
||||
|
||||
任务结果使用 completed/partial/blocked/cancelled,存在运行作业、最近动作失败或缺少
|
||||
必要工具证据时不接受 completed,并最多要求一次模型修正;这不是语义正确性的证明。
|
||||
长输出写入证据文件,`evidence_read` 按 ID、字节偏移和长度检索,避免重跑命令。
|
||||
默认活跃上下文预算上限为主 agent 48k、worker 24k,保留稳定系统/工具前缀。
|
||||
会话旁的 `.task-v2.json` 保存状态和有限事件历史,重启只展示历史,不重启外部操作。
|
||||
当前每个运行时最多保留 64 个热作业槽;已结束的槽可复用,旧状态和输出保留为文件,
|
||||
可继续按 job ID 查询。达到 64 个未结束作业时明确拒绝新作业。
|
||||
|
||||
本地验证:`tests/test_runtime` 覆盖版本失效、只读/委派控制、完成证据、异步等待、
|
||||
保护取消、恢复配置、私有 index 与三方集成冲突;`tests/test_subagent` 验证模型路由和预算。
|
||||
真实 API 的费用、延迟、缓存命中提升需要运行下面的付费评测,不从模拟测试推导。
|
||||
|
||||
## 单机默认
|
||||
|
||||
默认协作模式是 **solo / legacy_workers**(未设 `CST_COLLABORATION` 且无 open
|
||||
parliament meta)。本机可直接使用本节全部工具;密码 Realm 不会自动关掉它们。
|
||||
仅当显式 `collaboration=parliament`(或已有 open parliament)时,这些工具被隐藏,
|
||||
改走 `parliament_note`。详见 `docs/parliament.md`「单机默认」。
|
||||
|
||||
作业终态会入队完成事件:TUI 推送一行系统消息,下一模型回合注入 `[cst] job …`
|
||||
笔记,减少盲轮询 `job_wait`。
|
||||
|
||||
## 任务和续接
|
||||
|
||||
首次通过 `task` 指定 `agent`、`description` 和完整 `prompt`,结果包含 `worker_id`。
|
||||
再次调用 `task` 时传 `worker_id` 和新任务即可续接;历史、模型绑定与角色指令保存于
|
||||
主会话目录中,重启恢复主会话后仍可使用。模型配置变化适用于新 worker。
|
||||
运行中断不会自动重放工具;先检查当前文件状态再续接。
|
||||
|
||||
`max_steps` 可降低本次请求上限,不得提高角色或工作区上限。
|
||||
默认每次委派最多 20 个实际模型请求,主回合最多 8 次委派、100 个子 agent 请求。
|
||||
续接也算委派,重试、压缩、总结和缓存命中的请求都计数。达到上限后不再发请求,
|
||||
任务返回最后可用结果与失败原因,不额外调用模型总结。下一主回合重置共享预算。
|
||||
|
||||
TUI `/agents` 查看角色和运行记录,`/agents config` 查看配置,
|
||||
`/agents budget` 查看当前回合总量,`/agents cancel sub-1` 请求取消指定 worker。
|
||||
WebUI 显示 worker 模型、请求数、缓存 token 和续接/取消入口。
|
||||
每次委派的使用记录追加到 `sub-N.runs.jsonl`;最新状态在 `sub-N.json`。
|
||||
|
||||
## 缓存与验证
|
||||
|
||||
复用 worker 保留其历史前缀。角色指令位于环境信息前,任务差异作为新消息追加。
|
||||
支持缓存路由的平台使用包含工作区、模型、角色、系统提示和工具 schema 的稳定标识,
|
||||
相同配置的新 worker 也可利用公共前缀。其它平台仍使用自身隐式缓存或已有缓存断点。
|
||||
缓存数据未返回时显示未知,不把未知视为零命中;实际命中率取决于平台。
|
||||
|
||||
使用 `tests/test_subagent` 验证跨平台路由、持久化续接、预算、并行与取消。
|
||||
真实模型验收应固定模型版本、参数和任务,对比完成率、越界操作、无效调用、请求数及缓存。
|
||||
可运行 `bash scripts/eval_workers.sh --provider kimi-code --model k3`;设置
|
||||
`WORKER_EVAL_CONFIG` 为配置文件路径可对比编排方案。脚本在临时目录测试限定文件修改与
|
||||
长文件定位,保留输出、会话和 worker 使用记录,不删除评测材料。
|
||||
建议任务包含:限定单文件修复、读取截断输出、工具失败恢复、验证结果报告和主 agent 分派验收。
|
||||
不要在真实模型评测中使用生产密钥以外的敏感数据;评测会产生 API 费用。
|
||||
@@ -0,0 +1,23 @@
|
||||
# Hermit / 寄居蟹 · 扁平 SVG 动图包
|
||||
|
||||
设计原则:遵循原始草图。纯色块、橙色方形身体、紫色眼睛、短脚;无渐变、无阴影、无高光。蓝色终端方块就是寄居蟹的壳。
|
||||
|
||||
6 个状态 × 2 个版本,共 12 个 SVG:
|
||||
- idle:等待输入
|
||||
- question:请求问题
|
||||
- thinking:思考,黄色小钳子托腮
|
||||
- coding:写码,>_ 眼睛
|
||||
- success:完成
|
||||
- error:遇到问题
|
||||
|
||||
icons/{状态}-shell.svg 是大的带壳图标;icons/{状态}.svg 是小的无壳图标。透明背景、CSS 循环动画,支持系统减少动态效果。
|
||||
|
||||
直接打开 index.html 查看,支持深浅背景、暂停、速度调整、自动轮播以及两个版本的 SVG 下载。无联网依赖。
|
||||
|
||||
接入示例:<img src="icons/idle.svg" width="24" alt="等待输入">。大图标建议 64px 及以上,小图标建议 24–32px;16px 时状态细节较弱。使用内联 SVG 时可通过 --speed 设置速度。
|
||||
|
||||
普通字符终端不直接渲染 SVG;图标适用于支持图形的终端界面、Webview 或 CLI 伴随应用。
|
||||
|
||||
**TUI 双轨**:默认极简字符/色块帧见 `src/hermit_glyph.c`(idle 眨眼、coding `>_`、ask/thinking/success/error,与本目录 HTML 动画状态一致);`make hermit-font`(Pillow + fonttools,无需 rsvg)烘焙 `res/hermit-frames/` 与 `res/hermit-icons.ttf`(sbix,PUA `U+E700+`)。`make install` / bootstrap 会安装该字体;`HERMIT_ICONS=font` 时输出码点序列(终端需选用 Hermit Icons)。
|
||||
|
||||
更新:橘杏色身体、莓紫眼睛、低饱和蓝绿终端壳。预览主角色以 620ms 缓入缓出插值连续改变眼睛路径与身体姿态,支持中途切换;不重建主角色。状态符号淡入淡出。独立 SVG 保留各状态循环动效;跨状态变形逻辑位于 index.html。
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="coding" role="img" aria-label="Hermit 写码 带壳"><title>Hermit · 写码</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 21 4 4-4 4m10-1h5"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="coding" role="img" aria-label="Hermit 写码 无壳"><title>Hermit · 写码</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 21 4 4-4 4m10-1h5"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="error" role="img" aria-label="Hermit 遇到问题 带壳"><title>Hermit · 遇到问题</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 22 5 2m6 0 5-2"/></g><path class="hermit-mark" d="M52 12v6m0 5v.1" stroke="#9957ad" stroke-width="3" stroke-linecap="round"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="error" role="img" aria-label="Hermit 遇到问题 无壳"><title>Hermit · 遇到问题</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 22 5 2m6 0 5-2"/></g><path class="hermit-mark" d="M52 12v6m0 5v.1" stroke="#9957ad" stroke-width="3" stroke-linecap="round"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="idle" role="img" aria-label="Hermit 等待输入 带壳"><title>Hermit · 等待输入</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M29 23v5m10-5v5"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="idle" role="img" aria-label="Hermit 等待输入 无壳"><title>Hermit · 等待输入</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M29 23v5m10-5v5"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="question" role="img" aria-label="Hermit 请求问题 带壳"><title>Hermit · 请求问题</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M30 22v4m10-4v4"/></g><path class="hermit-mark" d="M47 13c0-6 8-6 8-1 0 3-4 3-4 6m0 5v.1" stroke="#9957ad" stroke-width="3" stroke-linecap="round" fill="none"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="question" role="img" aria-label="Hermit 请求问题 无壳"><title>Hermit · 请求问题</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M30 22v4m10-4v4"/></g><path class="hermit-mark" d="M47 13c0-6 8-6 8-1 0 3-4 3-4 6m0 5v.1" stroke="#9957ad" stroke-width="3" stroke-linecap="round" fill="none"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="success" role="img" aria-label="Hermit 完成 带壳"><title>Hermit · 完成</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 25 3-3 3 3m5 0 3-3 3 3"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="success" role="img" aria-label="Hermit 完成 无壳"><title>Hermit · 完成</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 25 3-3 3 3m5 0 3-3 3 3"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="thinking" role="img" aria-label="Hermit 思考 带壳"><title>Hermit · 思考</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M31 22v3m10-3v3"/></g><path class="hermit-mark" d="M43 32v7h-8" stroke="#e9ba47" stroke-width="6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="thinking" role="img" aria-label="Hermit 思考 无壳"><title>Hermit · 思考</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M31 22v3m10-3v3"/></g><path class="hermit-mark" d="M43 32v7h-8" stroke="#e9ba47" stroke-width="6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -111,8 +111,18 @@ typedef struct {
|
||||
char tools_off[1024]; /* ",name,name," tools switched off (/tools off) */
|
||||
/* Sub-agents (task tool). */
|
||||
int depth; /* 0 = top-level; sub-agents run at 1 */
|
||||
int orchestrate; /* coordinator delegates write/shell work */
|
||||
struct cst_runtime *runtime; /* V2 task controller; parent-owned */
|
||||
int orchestrate_override; /* CLI: 1 force on, -1 force off */
|
||||
int subagent_steps, subagent_tasks, subagent_total;
|
||||
void *worker_budget; /* owned by the top-level agent */
|
||||
int (*request_gate)(void *); /* reserve budget before each HTTP attempt */
|
||||
void *request_gate_ud;
|
||||
unsigned long task_revision;
|
||||
char cache_namespace[128];
|
||||
int read_only; /* no write / shell tools */
|
||||
char tools_only[1024]; /* ",a,b," allowlist; empty = every tool */
|
||||
char worker_scope[2048]; /* isolated worker's JSON path allowlist */
|
||||
const char *role_prompt; /* role instructions (not owned) */
|
||||
char undo_sid[128]; /* snapshot into this session's undo... */
|
||||
char undo_tag[64]; /* ...under this turn tag (the parent's) */
|
||||
@@ -267,6 +277,7 @@ typedef struct {
|
||||
long http_status;
|
||||
char finish_reason[32];
|
||||
long prompt_tokens;
|
||||
int cache_reported;
|
||||
long completion_tokens;
|
||||
long cached_tokens; /* prompt tokens read from the provider's cache */
|
||||
long cache_miss_tokens; /* prompt tokens billed at the full input price */
|
||||
@@ -283,6 +294,8 @@ typedef struct {
|
||||
const char *api_key;
|
||||
const char *model;
|
||||
const char *reasoning_effort; /* NULL = not sent */
|
||||
int (*request_gate)(void *);
|
||||
void *request_gate_ud;
|
||||
const cst_message_t *msgs;
|
||||
int nmsgs;
|
||||
cst_llm_on_delta_fn on_delta;
|
||||
@@ -377,6 +390,8 @@ cst_session_t *cst_agent_session(cst_agent_t *a);
|
||||
const cst_agent_config_t *cst_agent_config(const cst_agent_t *a);
|
||||
cst_agent_config_t *cst_agent_config_mut(cst_agent_t *a);
|
||||
void cst_agent_request_cancel(cst_agent_t *a);
|
||||
int cst_agent_steer(cst_agent_t *a, const char *message, int readonly, int delegation);
|
||||
int cst_agent_control(cst_agent_t *a, int readonly, int delegation, int paused);
|
||||
int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz);
|
||||
int cst_agent_switch_session(cst_agent_t *a, const char *id);
|
||||
/* Re-root the agent at another directory. Per-workspace state (permission
|
||||
@@ -433,6 +448,7 @@ long cst_agent_last_prompt_tokens(const cst_agent_t *a);
|
||||
/* Token use since the session was opened, and of the latest turn. */
|
||||
typedef struct {
|
||||
int requests;
|
||||
int cache_known_requests;
|
||||
long prompt, cached, miss, write, out;
|
||||
double cost; /* in currency; valid when priced */
|
||||
int priced; /* every request had a known price */
|
||||
@@ -461,6 +477,8 @@ int cst_creds_resolve(const char *provider, char *out, size_t out_sz,
|
||||
const char **from);
|
||||
/* Hidden console prompt (no echo). */
|
||||
int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz);
|
||||
/* Generic hidden prompt for passwords / secrets (TTY only). */
|
||||
int cst_prompt_secret(const char *prompt, char *out, size_t out_sz);
|
||||
|
||||
/* Last used provider, model per provider, effort and working mode, in
|
||||
* <config>/settings.json. */
|
||||
@@ -481,6 +499,8 @@ typedef struct {
|
||||
char effort[16];
|
||||
char mode[16]; /* coding | general, "" = not chosen yet */
|
||||
char approval[8]; /* "" = not chosen yet */
|
||||
/* legacy_workers | parliament — parliament replaces in-process subagents. */
|
||||
char collaboration[24];
|
||||
int show_thinking; /* TUI: Thought blocks start expanded */
|
||||
/* workspaces whose .agent/plugins and .agent/mcp.json may run */
|
||||
int ntrusted;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#ifndef CST_DISCOVER_H
|
||||
#define CST_DISCOVER_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Same-Realm peer discovery: UDP multicast beacon + local instance file.
|
||||
* mDNS (_cst-parliament._tcp) can replace the beacon later without changing
|
||||
* this surface. */
|
||||
|
||||
#define CST_DISCOVER_PORT 45454
|
||||
#define CST_DISCOVER_GROUP "239.255.77.77"
|
||||
|
||||
typedef struct {
|
||||
char instance_id[64];
|
||||
char realm_fp[33];
|
||||
char name[64];
|
||||
char host[64];
|
||||
int port;
|
||||
} cst_peer_info_t;
|
||||
|
||||
typedef struct cst_discover cst_discover_t;
|
||||
|
||||
/* Advertise this instance and collect peers that share realm_fp.
|
||||
* peer_port is the TCP parliament port peers should dial. */
|
||||
cst_discover_t *cst_discover_start(const char *realm_fp, const char *instance_id,
|
||||
const char *name, int peer_port);
|
||||
void cst_discover_stop(cst_discover_t *d);
|
||||
|
||||
/* Copy up to max known peers (excluding self). Returns count. */
|
||||
int cst_discover_peers(cst_discover_t *d, cst_peer_info_t *out, int max);
|
||||
|
||||
/* Same-machine registry under <config>/realm/local-instances.json. */
|
||||
int cst_discover_local_put(const char *realm_fp, const char *instance_id,
|
||||
const char *name, int peer_port);
|
||||
int cst_discover_local_del(const char *instance_id);
|
||||
int cst_discover_local_list(const char *realm_fp, cst_peer_info_t *out, int max);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,71 @@
|
||||
#ifndef CST_HERMIT_GLYPH_H
|
||||
#define CST_HERMIT_GLYPH_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <wchar.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Product display name (user-visible). Binary install name is "hermit". */
|
||||
#define HERMIT_NAME "Hermit"
|
||||
#define HERMIT_BIN "hermit"
|
||||
#define HERMIT_LEGACY_BIN "cst-agent"
|
||||
#define HERMIT_CONFIG_DIR "hermit"
|
||||
#define HERMIT_LEGACY_CONFIG_DIR "cst-agent"
|
||||
|
||||
/* Icon states mirror icons/agent-motion SVG set. */
|
||||
typedef enum {
|
||||
HERMIT_ST_IDLE = 0,
|
||||
HERMIT_ST_QUESTION,
|
||||
HERMIT_ST_THINKING,
|
||||
HERMIT_ST_CODING,
|
||||
HERMIT_ST_SUCCESS,
|
||||
HERMIT_ST_ERROR,
|
||||
HERMIT_ST_N
|
||||
} hermit_state_t;
|
||||
|
||||
typedef enum {
|
||||
HERMIT_ICON_AUTO = 0,
|
||||
HERMIT_ICON_MINI,
|
||||
HERMIT_ICON_FONT
|
||||
} hermit_icon_mode_t;
|
||||
|
||||
/* HERMIT_<SUFFIX> then CST_<SUFFIX>. Never returns NULL (may be ""). */
|
||||
const char *hermit_getenv(const char *suffix);
|
||||
|
||||
hermit_icon_mode_t hermit_icon_mode(void);
|
||||
/* Effective mode after AUTO resolution (ascii forces MINI). */
|
||||
hermit_icon_mode_t hermit_icon_mode_eff(int ascii);
|
||||
|
||||
/* Small badge UTF-8 (or ASCII). frame advances animation. */
|
||||
const char *hermit_mini_badge(hermit_state_t st, int frame, int ascii);
|
||||
|
||||
/* Home shell crab: up to *nlines strings (not owned). Returns line count. */
|
||||
int hermit_shell_art(hermit_state_t st, int with_shell, int ascii,
|
||||
const char **lines, int max_lines);
|
||||
/* Same as hermit_shell_art, but advances HTML-kit animation by frame. */
|
||||
int hermit_shell_art_frame(hermit_state_t st, int with_shell, int ascii,
|
||||
int frame, const char **lines, int max_lines);
|
||||
int hermit_shell_art_width(void);
|
||||
int hermit_shell_art_height(void);
|
||||
|
||||
/* Color roles for art cells: 0=body 1=shell 2=eye 3=leg 4=prompt 5=mark */
|
||||
int hermit_shell_cell_role(hermit_state_t st, int with_shell, int row, int col);
|
||||
|
||||
/* PUA base U+E700; layout: shell*HERMIT_ST_N*HERMIT_FONT_FRAMES + st*FRAMES + frame */
|
||||
#define HERMIT_FONT_PUA_BASE 0xE700
|
||||
#define HERMIT_FONT_FRAMES 8
|
||||
wchar_t hermit_font_codepoint(hermit_state_t st, int frame, int with_shell);
|
||||
|
||||
/* Non-empty path when hermit-icons.ttf is installed (may be ""). */
|
||||
const char *hermit_font_path(void);
|
||||
|
||||
/* Map agent UI signals → icon state. */
|
||||
hermit_state_t hermit_state_from_ui(int busy, int ask, int error);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,103 @@
|
||||
#ifndef CST_PARLIAMENT_H
|
||||
#define CST_PARLIAMENT_H
|
||||
|
||||
#include "cst_realm.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Parliament is one shared whole across agents. Password unlock claims the
|
||||
* single chair on the logging-in device; peers sync the same context. A new
|
||||
* project replaces all parliament context. */
|
||||
|
||||
typedef struct {
|
||||
char parliament_id[64];
|
||||
char project_id[64];
|
||||
char chair_instance[64];
|
||||
char chair_device[128];
|
||||
unsigned long revision;
|
||||
char status[32]; /* idle | open | dissolved */
|
||||
} cst_parliament_meta_t;
|
||||
|
||||
int cst_parliament_path(char *out, size_t n);
|
||||
int cst_parliament_load(cst_parliament_meta_t *m);
|
||||
int cst_parliament_save(const cst_parliament_meta_t *m);
|
||||
|
||||
/* Login: unlock Realm with password, enroll/ensure instance, claim chair,
|
||||
* return handover summary (malloc'd) or NULL. */
|
||||
char *cst_parliament_login(const char *password, const char *instance_id,
|
||||
const char *device_label, char *err, size_t err_sz);
|
||||
|
||||
/* Join as a peer seat without stealing the chair (claims only if vacant).
|
||||
* Returns JOIN_OK summary (malloc'd) or NULL. */
|
||||
char *cst_parliament_seat_join(const char *password, const char *instance_id,
|
||||
const char *device_label, char *err,
|
||||
size_t err_sz);
|
||||
|
||||
/* Only one chair: previous chair is demoted; handover notes left in meta. */
|
||||
int cst_parliament_claim_chair(const char *instance_id, const char *device_label);
|
||||
|
||||
/* Probe local environment as device-private context (malloc'd text). */
|
||||
char *cst_parliament_device_context(void);
|
||||
|
||||
/* Prefer CST_INSTANCE_ID, else sanitized hostname (never empty). */
|
||||
int cst_parliament_default_instance(char *out, size_t n);
|
||||
|
||||
/* New project: wipe shared parliament context; keep Realm keys. */
|
||||
int cst_parliament_new_project(const char *project_id);
|
||||
|
||||
/* Resolve collaboration mode: env CST_COLLABORATION, then settings,
|
||||
* then password-Realm with an open parliament → "parliament". */
|
||||
int cst_parliament_is_active(void);
|
||||
/* 1 when the local seat ticket matches the current chair. */
|
||||
int cst_parliament_is_chair(void);
|
||||
/* Short status lines for TUI/CLI (malloc'd; caller frees). */
|
||||
char *cst_parliament_status_text(void);
|
||||
|
||||
/* Append/replace synchronized context blob (all agents share this file). */
|
||||
int cst_parliament_context_put(const char *text);
|
||||
char *cst_parliament_context_get(void);
|
||||
/* Append a structured seat note and bump revision (triggers peer sync).
|
||||
* kind: status | agenda | result | handover | note */
|
||||
int cst_parliament_context_append(const char *seat_id, const char *kind,
|
||||
const char *body);
|
||||
|
||||
/* Ask the local parliament service to dial peers soon (best-effort). */
|
||||
void cst_parliament_sync_request(void);
|
||||
int cst_parliament_sync_pending(void);
|
||||
void cst_parliament_sync_ack(void);
|
||||
|
||||
/* Local login ticket: opens require a fresh seat session (or password env).
|
||||
* Any enrolled seat may hold a ticket; chair is tracked separately in meta. */
|
||||
int cst_parliament_session_write(const char *instance_id);
|
||||
int cst_parliament_session_ok(char *instance_id_out, size_t n);
|
||||
void cst_parliament_session_clear(void);
|
||||
|
||||
/* Apply a peer sync bundle (higher revision already selected by caller). */
|
||||
int cst_parliament_apply_sync(unsigned long revision, const char *parliament_id,
|
||||
const char *project_id, const char *chair,
|
||||
const char *context);
|
||||
|
||||
/* Background parliament service: discover + peer listen + context sync. */
|
||||
int cst_parliament_daemon_pidfile(char *out, size_t n);
|
||||
int cst_parliament_daemon_running(void);
|
||||
/* Read pidfile metadata. Returns 0 when a live service is recorded. */
|
||||
int cst_parliament_daemon_info(int *pid_out, char *instance_out, size_t in_sz,
|
||||
int *port_out);
|
||||
/* Start in background (Unix fork). password unlocks Realm in the child.
|
||||
* Returns 0 and writes pid. Restarts if a different instance/port is running. */
|
||||
int cst_parliament_daemon_start(const char *password, const char *instance_id,
|
||||
int peer_port, char *err, size_t err_sz);
|
||||
int cst_parliament_daemon_stop(void);
|
||||
/* Foreground service loop (used by --service and the forked child). */
|
||||
int cst_parliament_daemon_run(const char *password, const char *instance_id,
|
||||
int peer_port, char *err, size_t err_sz);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,37 @@
|
||||
#ifndef CST_PARLIAMENT_CLI_H
|
||||
#define CST_PARLIAMENT_CLI_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Standalone parliament/Realm ops for Docker and unattended smoke tests.
|
||||
* Returns a process exit code (0 = success). */
|
||||
int cst_parliament_cli_realm_new(void);
|
||||
int cst_parliament_cli_realm_password_init(const char *password);
|
||||
int cst_parliament_cli_login(const char *password, const char *instance_id,
|
||||
const char *device_label, int peer_port);
|
||||
/* Login then ensure background parliament service (discover + sync). */
|
||||
int cst_parliament_cli_login_and_serve(const char *password,
|
||||
const char *instance_id,
|
||||
const char *device_label, int peer_port);
|
||||
/* Join as member seat (no chair steal) then start background service. */
|
||||
int cst_parliament_cli_join_and_serve(const char *password,
|
||||
const char *instance_id,
|
||||
const char *device_label, int peer_port);
|
||||
int cst_parliament_cli_service(const char *password, const char *instance_id,
|
||||
int peer_port, int foreground);
|
||||
int cst_parliament_cli_service_stop(void);
|
||||
int cst_parliament_cli_new_project(const char *project_id);
|
||||
int cst_parliament_cli_realm_enroll(const char *instance_id, const char *label);
|
||||
int cst_parliament_cli_peer_listen(int port, const char *instance_id);
|
||||
int cst_parliament_cli_peer_dial(const char *host, int port,
|
||||
const char *instance_id);
|
||||
int cst_parliament_cli_discover(const char *instance_id, int peer_port,
|
||||
int wait_ms);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,59 @@
|
||||
#ifndef CST_PEER_H
|
||||
#define CST_PEER_H
|
||||
|
||||
#include "cst_realm.h"
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Mutual Realm certificate challenge over TCP, then ephemeral X25519 +
|
||||
* XChaCha20-Poly1305 (Monocypher) for the session. Same Realm public key
|
||||
* required on both sides. */
|
||||
|
||||
typedef struct {
|
||||
cst_realm_cert_t remote;
|
||||
char host[64];
|
||||
int port;
|
||||
intptr_t sock; /* open until cst_peer_close */
|
||||
uint8_t aead_key[32];
|
||||
uint64_t send_ctr;
|
||||
uint64_t recv_ctr;
|
||||
int sealed; /* 1 after key exchange */
|
||||
} cst_peer_session_t;
|
||||
|
||||
/* Dial host:port, prove our cert, verify theirs, establish AEAD session.
|
||||
* timeout_ms is a wall-clock budget for connect+handshake+kx (not per-read).
|
||||
* Leaves sock open for post-handshake sync; call cst_peer_close. */
|
||||
int cst_peer_dial(const char *host, int port, const cst_realm_cert_t *self,
|
||||
const uint8_t self_sk[64], const uint8_t realm_pk[32],
|
||||
int timeout_ms, cst_peer_session_t *out, char *err,
|
||||
size_t err_sz);
|
||||
|
||||
/* Accept one connection on listening socket (from os_net_listen). */
|
||||
int cst_peer_accept(intptr_t listen_sock, const cst_realm_cert_t *self,
|
||||
const uint8_t self_sk[64], const uint8_t realm_pk[32],
|
||||
int timeout_ms, cst_peer_session_t *out, char *err,
|
||||
size_t err_sz);
|
||||
|
||||
void cst_peer_close(cst_peer_session_t *s);
|
||||
|
||||
/* After handshake: exchange parliament revision/context over the sealed
|
||||
* channel. Higher revision wins. Returns 0 on success. */
|
||||
typedef struct {
|
||||
unsigned long revision;
|
||||
char parliament_id[64];
|
||||
char project_id[64];
|
||||
char chair_instance[64];
|
||||
char *context; /* malloc'd when loading; freed by caller after sync */
|
||||
} cst_peer_sync_bundle_t;
|
||||
|
||||
int cst_peer_sync(cst_peer_session_t *s, cst_peer_sync_bundle_t *local,
|
||||
int timeout_ms, char *err, size_t err_sz);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,74 @@
|
||||
#ifndef CST_REALM_H
|
||||
#define CST_REALM_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* User Realm keys (Ed25519). The seed is the durable secret; sk is the
|
||||
* Monocypher expanded secret (seed||pk). Same Realm public key ⇒ peers may
|
||||
* auto-discover and auto-connect. */
|
||||
typedef struct cst_realm_keys {
|
||||
uint8_t seed[32];
|
||||
uint8_t sk[64];
|
||||
uint8_t pk[32];
|
||||
} cst_realm_keys_t;
|
||||
|
||||
/* Instance certificate signed by the Realm private key. */
|
||||
typedef struct cst_realm_cert {
|
||||
char instance_id[64];
|
||||
char label[128];
|
||||
uint8_t instance_pk[32];
|
||||
int64_t not_after; /* unix seconds; 0 = no expiry */
|
||||
uint8_t realm_pk[32];
|
||||
uint8_t sig[64];
|
||||
} cst_realm_cert_t;
|
||||
|
||||
/* Directory helpers: <config>/realm/{private.key,public.key,revoked}. */
|
||||
int cst_realm_dir(char *out, size_t n);
|
||||
int cst_realm_generate(cst_realm_keys_t *out);
|
||||
int cst_realm_derive(cst_realm_keys_t *out, const uint8_t seed[32]);
|
||||
int cst_realm_save(const char *dir, const cst_realm_keys_t *k);
|
||||
int cst_realm_load(const char *dir, cst_realm_keys_t *out);
|
||||
/* 32 hex chars (blake2b-128 of pk). */
|
||||
int cst_realm_fingerprint(const uint8_t pk[32], char out[33]);
|
||||
|
||||
/* Password-string Realm: Argon2id(password, salt) → seed. Private key is not
|
||||
* stored; only salt + public.key live on disk. Unlock derives sk in memory. */
|
||||
int cst_realm_derive_password(cst_realm_keys_t *out, const char *password,
|
||||
const uint8_t salt[16]);
|
||||
int cst_realm_init_password(const char *dir, const char *password);
|
||||
int cst_realm_unlock_password(const char *dir, const char *password,
|
||||
cst_realm_keys_t *out);
|
||||
int cst_realm_is_password_mode(const char *dir);
|
||||
|
||||
int cst_realm_enroll(const cst_realm_keys_t *realm, const char *instance_id,
|
||||
const char *label, int64_t not_after, cst_realm_cert_t *cert,
|
||||
uint8_t instance_seed[32], uint8_t instance_sk[64]);
|
||||
int cst_realm_verify_cert(const uint8_t realm_pk[32], const cst_realm_cert_t *cert,
|
||||
int64_t now_unix);
|
||||
char *cst_realm_cert_to_json(const cst_realm_cert_t *cert);
|
||||
int cst_realm_cert_from_json(const char *json, cst_realm_cert_t *out);
|
||||
|
||||
int cst_realm_challenge_sign(const uint8_t instance_sk[64], const void *chal,
|
||||
size_t n, uint8_t sig[64]);
|
||||
int cst_realm_challenge_check(const cst_realm_cert_t *cert, const void *chal,
|
||||
size_t n, const uint8_t sig[64]);
|
||||
|
||||
int cst_realm_revoke_add(const char *dir, const char *instance_id);
|
||||
int cst_realm_revoked(const char *dir, const char *instance_id);
|
||||
|
||||
/* Persist an enrolled instance under <realm>/instances/<id>/. */
|
||||
int cst_realm_instance_save(const char *instance_id, const cst_realm_cert_t *cert,
|
||||
const uint8_t instance_seed[32]);
|
||||
int cst_realm_instance_load(const char *instance_id, cst_realm_cert_t *cert,
|
||||
uint8_t instance_sk[64]);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,59 @@
|
||||
#ifndef CST_RUNTIME_H
|
||||
#define CST_RUNTIME_H
|
||||
#include "cst_agent.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
typedef struct cst_runtime cst_runtime_t;
|
||||
typedef int (*cst_job_fn)(void *data, volatile int *cancel, cst_tool_result_t *result);
|
||||
typedef void (*cst_job_free_fn)(void *data);
|
||||
|
||||
/* One finished job ready for TUI / next-turn agent notes. */
|
||||
typedef struct cst_job_completion {
|
||||
char id[32];
|
||||
char kind[24];
|
||||
char status[32]; /* completed | failed | cancelled */
|
||||
char summary[200];
|
||||
int exit_code;
|
||||
long ms;
|
||||
} cst_job_completion_t;
|
||||
|
||||
cst_runtime_t *cst_runtime_open(const char *session_path);
|
||||
void cst_runtime_close(cst_runtime_t *r);
|
||||
unsigned long cst_runtime_begin(cst_runtime_t *r, const char *goal);
|
||||
unsigned long cst_runtime_revision(cst_runtime_t *r);
|
||||
int cst_runtime_steer(cst_runtime_t *r, const char *message, int readonly, int delegation);
|
||||
char *cst_runtime_take_steering(cst_runtime_t *r);
|
||||
int cst_runtime_controls(cst_runtime_t *r, int readonly, int delegation, int paused);
|
||||
int cst_runtime_check(cst_runtime_t *r, unsigned long revision, int mutating, int delegate);
|
||||
void cst_runtime_cancel(cst_runtime_t *r, const char *source, int force);
|
||||
char *cst_runtime_evidence(cst_runtime_t *r, const char *tool, int exit_code,
|
||||
const char *output, int mutating);
|
||||
int cst_runtime_finish(cst_runtime_t *r, const char *status, const char *summary,
|
||||
int action, char *err, size_t err_sz);
|
||||
char *cst_runtime_json(cst_runtime_t *r);
|
||||
int cst_job_start(cst_runtime_t *r, const char *kind, const char *resource,
|
||||
int interruptible, cst_job_fn fn, void *data,
|
||||
cst_job_free_fn destroy, char *id, size_t id_sz);
|
||||
/* Waits at most timeout_ms; output is a JSON snapshot, not a destructive read. */
|
||||
char *cst_job_read(cst_runtime_t *r, const char *id, int timeout_ms);
|
||||
int cst_job_cancel(cst_runtime_t *r, const char *id, int force, const char *source);
|
||||
int cst_runtime_jobs_pending(cst_runtime_t *r);
|
||||
int cst_runtime_current_jobs_pending(cst_runtime_t *r);
|
||||
int cst_runtime_resource_busy(cst_runtime_t *r, const char *resource);
|
||||
int cst_runtime_wait(cst_runtime_t *r, int timeout_ms);
|
||||
/* Drain finished-job reports for the agent loop (destructive). */
|
||||
int cst_runtime_take_completions(cst_runtime_t *r, cst_job_completion_t *out,
|
||||
int max);
|
||||
/* Drain the UI fan-out queue (destructive; independent of take_completions). */
|
||||
int cst_runtime_take_ui_completions(cst_runtime_t *r, cst_job_completion_t *out,
|
||||
int max);
|
||||
/* Model tools, shared by every front end. */
|
||||
int cst_runtime_register_tools(void);
|
||||
struct cst_tool_ctx;
|
||||
int cst_runtime_shell(const struct cst_tool_ctx *ctx, const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *result);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -3,18 +3,20 @@
|
||||
|
||||
/*
|
||||
* Sub-agents: the task(description, prompt, agent?) tool runs one turn of a
|
||||
* child agent with its own context and returns its final report.
|
||||
* child agent with its own context and returns its final report and worker_id.
|
||||
* Pass worker_id on subsequent calls to continue the persisted context.
|
||||
*
|
||||
* Roles: built-in general / explore / plan, plus Markdown files in
|
||||
* <ws>/.agent/agents/ and <config>/agents/ (the workspace wins, both win
|
||||
* over built-ins). Frontmatter: name, description, model, tools (allowlist),
|
||||
* max_steps, readonly; the body is the role's instructions.
|
||||
* profile, max_steps, readonly; the body is the role's instructions.
|
||||
*
|
||||
* Transcripts: <ws>/.agent/sessions/<parent-id>/sub-<n>.jsonl, with a
|
||||
* sub-<n>.json summary next to each (not listed as sessions).
|
||||
*/
|
||||
|
||||
#include <stddef.h>
|
||||
#include "cst_agent.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -27,6 +29,7 @@ typedef struct {
|
||||
char desc[256];
|
||||
char source[16]; /* "builtin" | "workspace" | "user" */
|
||||
char model[128]; /* empty = the parent's */
|
||||
char profile[64];
|
||||
char tools[1024]; /* ",a,b," allowlist; empty = all but task */
|
||||
int max_steps; /* 0 = default */
|
||||
int read_only;
|
||||
@@ -46,11 +49,30 @@ typedef struct {
|
||||
char role[64];
|
||||
char desc[160];
|
||||
char status[32]; /* "done" | "error" | "cancelled" | "running" */
|
||||
char provider[32], model[128];
|
||||
int requests, max_steps;
|
||||
long prompt_tokens, cached_tokens, cache_write_tokens, output_tokens;
|
||||
int priced;
|
||||
int cache_known;
|
||||
double cost;
|
||||
char currency[8];
|
||||
int steps, tools;
|
||||
double ms;
|
||||
long long mtime;
|
||||
} cst_subagent_rec_t;
|
||||
|
||||
void *cst_worker_budget_new(void);
|
||||
void cst_worker_budget_free(void *budget);
|
||||
void cst_worker_budget_reset(void *budget);
|
||||
char *cst_workers_budget_json(const cst_agent_config_t *cfg);
|
||||
/* Validated JSON configuration, stored in <workspace>/.agent/workers.json. */
|
||||
char *cst_workers_config_get(const char *workspace);
|
||||
int cst_workers_config_set(const char *workspace, const char *json,
|
||||
char *err, size_t err_sz);
|
||||
void cst_workers_config_apply(cst_agent_config_t *cfg);
|
||||
int cst_worker_cancel(const char *workspace, const char *parent_id,
|
||||
const char *worker_id);
|
||||
|
||||
/* This parent session's sub-agent runs, newest first. */
|
||||
int cst_subagent_list(const char *workspace, const char *parent_id,
|
||||
cst_subagent_rec_t *out, int max);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#ifndef CST_VERSION_H
|
||||
#define CST_VERSION_H
|
||||
|
||||
#define CST_VERSION_MAJOR 0
|
||||
#define CST_VERSION_MINOR 9
|
||||
#define CST_VERSION_PATCH 8
|
||||
#define CST_VERSION_MAJOR 1
|
||||
#define CST_VERSION_MINOR 1
|
||||
#define CST_VERSION_PATCH 0
|
||||
|
||||
#define CST_VERSION_STR_(x) #x
|
||||
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#ifndef CST_WORKTREE_H
|
||||
#define CST_WORKTREE_H
|
||||
#include <stddef.h>
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
/* files is a JSON array of workspace-relative paths. No caller index changes. */
|
||||
int cst_worktree_create(const char *workspace, const char *files, char *out,
|
||||
size_t out_sz, char *err, size_t err_sz);
|
||||
char *cst_worktree_diff(const char *workspace, const char *worktree, int integrate,
|
||||
int *code);
|
||||
int cst_worktree_path_allowed(const char *files, const char *relative_path);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -130,6 +130,9 @@ void os_set_cancel_flag(volatile int *flag);
|
||||
volatile int *os_get_cancel_flag(void);
|
||||
void os_set_run_timeout(int seconds); /* this thread; 0 = default */
|
||||
int os_get_run_timeout(void);
|
||||
typedef struct { void (*write)(const char *, size_t, void *); void *userdata; } os_output_observer_t;
|
||||
void os_set_output_observer(os_output_observer_t *observer);
|
||||
void os_observe_output(const char *data, size_t n);
|
||||
|
||||
/* Long-lived child with pipes on stdin/stdout (MCP servers). stderr goes
|
||||
* to stderr_path (NULL = discarded). env_add: "KEY=VALUE" entries added to
|
||||
@@ -210,6 +213,9 @@ int os_utf8_encode(unsigned long cp, char out[4]);
|
||||
* text as GBK and the screen would show mojibake. */
|
||||
int os_utf8_decode(const char *s, size_t avail, unsigned long *cp);
|
||||
|
||||
/* HERMIT_<suffix> then CST_<suffix>. Never NULL (may be ""). */
|
||||
const char *hermit_getenv(const char *suffix);
|
||||
|
||||
/* --- libc gaps on Windows ------------------------------------------------
|
||||
* MinGW-w64 provides neither strcasestr() (a GNU extension) nor
|
||||
* setenv()/unsetenv() (POSIX): they are simply absent from its headers.
|
||||
|
||||