commit 1f1fcba7ad7a85d08675b2099c74c1c0ea2808fb
Author: WHC
Date: Mon Sep 7 22:42:32 2026 +0800
Initial CST-Agent: portable Linux/Termux/Windows coding agent.
Vendored sashidown, OpenAI-compatible env (OPENAI_*), and bootstrap scripts.
Co-authored-by: Cursor
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a808ffe
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,25 @@
+*.o
+cst-agent
+cst-agent.exe
+tests/test_*
+!tests/test_*.c
+session.jsonl
+.agent/
+fixtures/mini_calc/out/
+fixtures/mini_calc/calc
+fixtures/snake_cpp/snake
+fixtures/snake_cpp/out/
+build/
+build-*/
+dist/
+third_party/busybox/busybox
+third_party/busybox/busybox.exe
+third_party/curl/
+third_party/PDCurses/
+third_party/busybox-w32/
+third_party/busybox-src/
+third_party/prefix-mingw/
+vendor/sashidown/*.o
+vendor/sashidown/src/*.o
+vendor/sashidown/libsashidown.a
+vendor/sashidown/libsashidown-mingw.a
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..aa04d83
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,160 @@
+cmake_minimum_required(VERSION 3.16)
+project(cst-agent C)
+
+set(CMAKE_C_STANDARD 11)
+set(CMAKE_C_STANDARD_REQUIRED ON)
+
+option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" ON)
+option(CST_NATIVE_ARCH "Pass -march=native to sashidown" OFF)
+option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON)
+
+set(SD_DIR "${CMAKE_CURRENT_SOURCE_DIR}/vendor/sashidown")
+if(CST_NATIVE_ARCH)
+ set(_SD_MAKE_EXTRA CST_NATIVE_ARCH=1)
+else()
+ set(_SD_MAKE_EXTRA "")
+endif()
+
+# Separate archive for MinGW so Linux host builds keep libsashidown.a
+if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
+ set(SASHIDOWN_A "${SD_DIR}/libsashidown-mingw.a")
+ set(_MINGW_AR x86_64-w64-mingw32-ar)
+ add_custom_command(
+ OUTPUT "${SASHIDOWN_A}"
+ COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean
+ COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" libsashidown.a
+ CC=${CMAKE_C_COMPILER} AR=${_MINGW_AR} RANLIB=x86_64-w64-mingw32-ranlib
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different
+ "${SD_DIR}/libsashidown.a" "${SASHIDOWN_A}"
+ COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean
+ COMMENT "Building libsashidown for MinGW"
+ VERBATIM)
+else()
+ set(SASHIDOWN_A "${SD_DIR}/libsashidown.a")
+ add_custom_command(
+ OUTPUT "${SASHIDOWN_A}"
+ COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" libsashidown.a CC=${CMAKE_C_COMPILER} ${_SD_MAKE_EXTRA}
+ COMMENT "Building libsashidown.a"
+ VERBATIM)
+endif()
+add_custom_target(sashidown DEPENDS "${SASHIDOWN_A}")
+
+find_package(CURL REQUIRED)
+find_package(Threads REQUIRED)
+
+set(CST_SOURCES
+ src/ctl_parse.c
+ src/os_compat.c
+ src/sandbox.c
+ src/session.c
+ src/tools/tools.c
+ src/llm_openai.c
+ src/agent_loop.c
+ src/agent_store.c
+ src/json_baseline.c
+ src/creds.c
+ src/mm_tool.c
+ src/models.c
+ src/main.c
+)
+
+if(CST_BUILD_TUI)
+ list(APPEND CST_SOURCES src/tui.c)
+else()
+ list(APPEND CST_SOURCES src/tui_stub.c)
+endif()
+
+add_executable(cst-agent ${CST_SOURCES})
+add_dependencies(cst-agent sashidown)
+
+target_include_directories(cst-agent PRIVATE
+ include
+ "${SD_DIR}/include"
+)
+
+target_compile_definitions(cst-agent PRIVATE _GNU_SOURCE)
+if(NOT WIN32)
+ target_compile_definitions(cst-agent PRIVATE _XOPEN_SOURCE_EXTENDED)
+endif()
+
+target_link_libraries(cst-agent PRIVATE
+ "${SASHIDOWN_A}"
+ CURL::libcurl
+ Threads::Threads
+)
+if(NOT WIN32)
+ target_link_libraries(cst-agent PRIVATE m)
+endif()
+
+if(CST_BUILD_TUI)
+ if(WIN32)
+ # Prefer PDCurses (pdcurses / curses). Users may set PDCurses_ROOT.
+ find_path(PDCURSES_INCLUDE_DIR curses.h
+ HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}"
+ PATH_SUFFIXES include)
+ find_library(PDCURSES_LIBRARY
+ NAMES pdcurses pdcurses.dll curses
+ HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}"
+ PATH_SUFFIXES lib)
+ if(PDCURSES_INCLUDE_DIR AND PDCURSES_LIBRARY)
+ target_include_directories(cst-agent PRIVATE ${PDCURSES_INCLUDE_DIR})
+ target_link_libraries(cst-agent PRIVATE ${PDCURSES_LIBRARY})
+ target_compile_definitions(cst-agent PRIVATE CST_USE_PDCURSES=1)
+ message(STATUS "TUI: PDCurses (${PDCURSES_LIBRARY})")
+ else()
+ message(FATAL_ERROR "CST_BUILD_TUI=ON on Windows requires PDCurses (set PDCurses_ROOT)")
+ endif()
+ else()
+ set(CURSES_NEED_WIDE TRUE)
+ find_package(Curses)
+ if(CURSES_FOUND)
+ target_include_directories(cst-agent PRIVATE ${CURSES_INCLUDE_DIRS})
+ target_link_libraries(cst-agent PRIVATE ${CURSES_LIBRARIES})
+ else()
+ # Fallback common on Linux distros
+ target_link_libraries(cst-agent PRIVATE ncursesw)
+ endif()
+ target_compile_definitions(cst-agent PRIVATE CST_USE_NCURSES=1)
+ endif()
+else()
+ target_compile_definitions(cst-agent PRIVATE CST_HEADLESS_ONLY=1)
+endif()
+
+if(CMAKE_SYSTEM_NAME STREQUAL "Windows" OR WIN32)
+ # curl Schannel + networking
+ target_link_libraries(cst-agent PRIVATE
+ ws2_32 crypt32 secur32 bcrypt advapi32)
+endif()
+
+# --- tests ---
+enable_testing()
+foreach(t test_ctl_frontier test_json_baseline test_replay_starvation test_mm_tool)
+ if(t STREQUAL test_ctl_frontier)
+ add_executable(${t} tests/test_ctl_frontier.c src/ctl_parse.c)
+ elseif(t STREQUAL test_json_baseline)
+ add_executable(${t} tests/test_json_baseline.c src/json_baseline.c)
+ elseif(t STREQUAL test_replay_starvation)
+ add_executable(${t} tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c)
+ else()
+ add_executable(${t} tests/test_mm_tool.c src/mm_tool.c)
+ endif()
+ target_include_directories(${t} PRIVATE include)
+ target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
+ add_test(NAME ${t} COMMAND ${t})
+endforeach()
+
+# --- busybox bundle helpers ---
+if(CST_BUNDLE_BUSYBOX)
+ install(FILES
+ "${CMAKE_CURRENT_SOURCE_DIR}/third_party/busybox/README.md"
+ DESTINATION .
+ OPTIONAL)
+ if(WIN32 AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/busybox/busybox.exe")
+ install(PROGRAMS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/busybox/busybox.exe" DESTINATION .)
+ endif()
+endif()
+
+install(TARGETS cst-agent RUNTIME DESTINATION .)
+
+message(STATUS "Compiler: ${CMAKE_C_COMPILER_ID} ${CMAKE_C_COMPILER}")
+message(STATUS "CST_BUILD_TUI=${CST_BUILD_TUI}")
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..aaf975e
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,77 @@
+CC ?= gcc
+CFLAGS ?= -std=c11 -Wall -Wextra -O2 -g
+SD_DIR := vendor/sashidown
+CPPFLAGS += -Iinclude -I$(SD_DIR)/include -D_XOPEN_SOURCE_EXTENDED
+LDFLAGS += -L$(SD_DIR)
+LDLIBS += -lsashidown -lm -lcurl -lncursesw -lpthread
+
+SRC = \
+ src/ctl_parse.c \
+ src/os_compat.c \
+ src/sandbox.c \
+ src/session.c \
+ src/tools/tools.c \
+ src/llm_openai.c \
+ src/agent_loop.c \
+ src/agent_store.c \
+ src/json_baseline.c \
+ src/creds.c \
+ src/mm_tool.c \
+ src/models.c \
+ src/tui.c \
+ src/main.c
+
+OBJ = $(SRC:.c=.o)
+
+.PHONY: all clean check demo sashidown-lib cmake-gcc cmake-clang
+
+all: sashidown-lib cst-agent
+
+# Optional CMake entrypoints (same sources)
+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-clang:
+ cmake -B build-clang -DCMAKE_C_COMPILER=clang -DCST_BUILD_TUI=ON
+ cmake --build build-clang
+
+sashidown-lib:
+ $(MAKE) -C $(SD_DIR) libsashidown.a
+
+cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
+ $(CC) $(CFLAGS) -o $@ $(OBJ) $(LDFLAGS) $(LDLIBS)
+
+src/%.o: src/%.c include/ctl.h include/cst_agent.h
+ $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
+
+src/tools/%.o: src/tools/%.c include/ctl.h include/cst_agent.h
+ $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
+
+src/tui.o: src/tui.c include/cst_agent.h $(SD_DIR)/include/sashidown.h
+ $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
+
+tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h
+ $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_frontier.c src/ctl_parse.c
+
+tests/test_json_baseline: tests/test_json_baseline.c src/json_baseline.c include/cst_agent.h
+ $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_json_baseline.c src/json_baseline.c
+
+tests/test_replay_starvation: tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c
+ $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c
+
+tests/test_mm_tool: tests/test_mm_tool.c src/mm_tool.c include/mm_tool.h
+ $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_mm_tool.c src/mm_tool.c
+
+check: tests/test_ctl_frontier tests/test_json_baseline tests/test_replay_starvation tests/test_mm_tool
+ ./tests/test_ctl_frontier
+ ./tests/test_json_baseline
+ ./tests/test_replay_starvation
+ ./tests/test_mm_tool
+
+demo: cst-agent
+ ./cst-agent --demo-ctl --workspace .
+
+clean:
+ rm -f $(OBJ) cst-agent tests/test_ctl_frontier tests/test_json_baseline tests/test_replay_starvation tests/test_mm_tool
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4bfd916
--- /dev/null
+++ b/README.md
@@ -0,0 +1,198 @@
+# CST-Agent
+
+C11 OpenCode-like carrier that extends paper **CST** (confirmation frontier / irreversible emission) from Markdown→HTML to the **agent tool loop**.
+
+See [docs/theory_tool_cst.md](docs/theory_tool_cst.md).
+评测/反哺论文素材:[docs/eval_tool_cst.md](docs/eval_tool_cst.md)。
+
+## 能不能用来工作?
+
+**可以**:有监督的本地小改动 / 探索本仓库(TUI + 工具 + 权限确认)。
+**不宜**:无人值守长任务或当 CI 主力(网关偶发空答、无完整 diff UI)。
+
+### 5 分钟试用
+
+```bash
+# 一键拉取编译(Linux)
+curl -fsSL https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/bootstrap_linux.sh | bash
+# 或已有仓库:
+cd CST-agent && ./scripts/bootstrap_linux.sh .
+
+# OpenAI 官方
+export OPENAI_API_KEY='sk-...'
+export OPENAI_BASE_URL='https://api.openai.com/v1'
+export OPENAI_MODEL='gpt-4.1-mini'
+./cst-agent --workspace .
+```
+
+完整环境变量说明:[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 交叉构建(在 Linux 上) | [`scripts/bootstrap_windows.sh`](scripts/bootstrap_windows.sh) → `dist/windows-x86_64/` |
+| Windows 运行 | 拷贝 dist 后 [`scripts/run_windows.bat`](scripts/run_windows.bat) |
+
+### 迷你验收工程(论文案例)
+
+故意带 bug 的小计算器:[`fixtures/mini_calc/`](fixtures/mini_calc/)(任务书 `TASK.md`)。
+
+```bash
+./scripts/e2e_mini_calc.sh --baseline-only # 无 API:证明初始测试 RED
+./scripts/e2e_mini_calc.sh # 有 key:agent 修复后 make test
+```
+
+口径:`pass` / `partial` / `fail` — 见 [docs/eval_tool_cst.md](docs/eval_tool_cst.md)。
+
+## Build
+
+### Make(Linux 默认)
+
+```bash
+cd agent
+make
+make check
+```
+
+### CMake(推荐跨编译器 / 交叉)
+
+```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
+
+# Headless only(无 ncurses)
+cmake -B build-hd -DCST_BUILD_TUI=OFF && cmake --build build-hd
+
+# Windows MinGW 交叉(需安装 mingw-w64)
+cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
+ -DCST_BUILD_TUI=OFF
+cmake --build build-win
+# TUI:安装 PDCurses 后 -DCST_BUILD_TUI=ON -DPDCurses_ROOT=...
+```
+
+依赖:`gcc` 或 `clang`、`libcurl`、Linux TUI 需 `ncursesw`;Windows TUI 需 **PDCurses**;sibling `../SD`(`libsashidown.a`)。
+API Key:env / 配置文件 / **zenity(Linux)或控制台输入(Windows / 无 zenity)**。
+
+## 平台支持矩阵
+
+| OS | Arch | 编译器 | Headless | TUI | Shell |
+|---|---|---|---|---|---|
+| Linux | x86_64 / aarch64 | GCC, Clang | yes | ncursesw | `/bin/sh` 或旁路 `busybox` |
+| Android Termux | aarch64 / … | Clang (Termux) | yes | ncurses(Termux 包) | `$PREFIX/bin/sh`;可旁路 busybox |
+| Windows | x86_64 / aarch64 | MinGW GCC/Clang | yes | PDCurses | **捆绑 busybox-w32** |
+| macOS | — | — | **不做** | — | — |
+
+### Termux
+
+```bash
+pkg install clang make cmake libcurl ncurses-ui-libs
+cmake -B build -DCST_BUILD_TUI=ON && cmake --build build
+# 可选:旁路 Unix busybox
+# ./scripts/build_third_party_mingw.sh busybox-unix
+```
+
+环境:`PREFIX` 自动用于找 `sh`;`CST_EXE_DIR` / `argv[0]` 用于定位旁路 busybox(`/proc/self/exe` 受限时)。
+
+### Windows 发行包
+
+见 [docs/README-windows.md](docs/README-windows.md)。交叉构建:
+
+```bash
+./scripts/build_third_party_mingw.sh all # curl+PDCurses+busybox-w32
+# 源码:https://github.com/rmyorston/busybox-w32.git
+# https://github.com/mirror/busybox.git (Unix)
+cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
+ -DCST_BUILD_TUI=ON -DCMAKE_PREFIX_PATH=$PWD/third_party/prefix-mingw
+```
+
+产物示例:`dist/windows-x86_64/`(含 `cst-agent.exe` + `busybox.exe` + `libwinpthread-1.dll`)。
+
+环境变量:`CST_SHELL`、`CST_EXE_DIR`、`CST_AUTO_APPROVE` 等。
+
+## Defaults (API)
+
+默认 MiniMax:`https://api.minimax.cn/v1` / `MiniMax-M2.7`。
+对接 **OpenAI 官方或其它兼容网关** 时设置:
+
+| 用途 | 推荐变量 | 等价别名 |
+|---|---|---|
+| Key | `OPENAI_API_KEY` | `CST_API_KEY`、`MINIMAX_API_KEY` |
+| Base(含 `/v1`) | `OPENAI_BASE_URL` | `CST_API_BASE`、`OPENAI_API_BASE` |
+| Model | `OPENAI_MODEL` | `CST_MODEL` |
+
+详见 [docs/env_openai.md](docs/env_openai.md)。
+
+| 项 | 默认(未设 OPENAI_* 时) |
+|---|---|
+| API Base | `https://api.minimax.cn/v1` |
+| Model | `MiniMax-M2.7` |
+| API Key | env → 配置目录 credentials → zenity/控制台 |
+
+环境变量:`CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR`。
+
+## Run
+
+```bash
+# Local CTL demo (no API)
+./cst-agent --demo-ctl --workspace .
+
+# TUI(OpenCode 风格;Assistant Markdown 经 Sashidown CST 渲染)
+./cst-agent --workspace ..
+
+# 强制重新弹窗保存 Key
+./cst-agent --ask-key --workspace .
+
+# Headless(默认 auto-approve shell/写文件)
+./cst-agent --headless --prompt 'List files' --workspace .
+```
+
+### TUI 快捷键与斜杠命令
+
+| 操作 | 说明 |
+|---|---|
+| Enter | 发送;空输入展开最近 tool 卡;busy 时入队(最多 3) |
+| Ctrl+N | 输入换行(多行 composer) |
+| ↑ / ↓ | 提示历史 |
+| Esc / Ctrl+C | busy 时取消当前 turn |
+| Tab / `t`(空输入) | 展开/折叠 Thought |
+| 权限 | shell/写文件:`y` 允许 · `n` 拒绝 · `a` 本进程对该工具 Always |
+| `/new` | 新会话 |
+| `/session` | **可点选**会话列表(↑↓ Enter);`/session ` 手打切换 |
+| `/model` | **可点选**模型;`/model NAME` 直接设置(进程内) |
+| `/compact` | 压缩上下文 |
+| `/details` | 展开/折叠全部 tool 输出 |
+| `/help` | 帮助 |
+
+会话落盘:`/.agent/sessions/.jsonl` + `CURRENT`。
+
+### 协议
+
+- OpenAI 兼容 `POST …/chat/completions`:原生 `tools` / SSE `tool_calls`,answer 轮 `tool_choice:none`
+- MiniMax XML `` 作 fallback
+- Assistant 仅工具时 `content: null` + `tool_calls`(对齐 OpenCode/OpenAI Chat)
+
+## CTL tool language
+
+```text
+call
+arg
+
+end
+```
+
+Execution happens only on confirmed `end` (`CTL_EV_CALL_READY`).
+
+## Starvation experiment
+
+```bash
+make check
+./scripts/replay_starvation.sh
+```
+
+CTL vs JSON-buffer 截断数字写入 [docs/eval_tool_cst.md](docs/eval_tool_cst.md) 实验 A;与 mini_calc e2e 一起作为 Tool-loop CST 附录素材(**不**替代 SD 主论文 Markdown→HTML 实验)。
diff --git a/cmake/mingw-w64-aarch64.cmake b/cmake/mingw-w64-aarch64.cmake
new file mode 100644
index 0000000..c234e46
--- /dev/null
+++ b/cmake/mingw-w64-aarch64.cmake
@@ -0,0 +1,12 @@
+# Usage:
+# cmake -B build-win-arm -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-aarch64.cmake \
+# -DCST_BUILD_TUI=OFF
+set(CMAKE_SYSTEM_NAME Windows)
+set(CMAKE_SYSTEM_PROCESSOR aarch64)
+
+set(CMAKE_C_COMPILER aarch64-w64-mingw32-gcc)
+set(CMAKE_CXX_COMPILER aarch64-w64-mingw32-g++)
+
+set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
+set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
+set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
diff --git a/cmake/mingw-w64-x86_64.cmake b/cmake/mingw-w64-x86_64.cmake
new file mode 100644
index 0000000..c0263eb
--- /dev/null
+++ b/cmake/mingw-w64-x86_64.cmake
@@ -0,0 +1,21 @@
+# Usage:
+# ./scripts/build_third_party_mingw.sh curl
+# cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
+# -DCST_BUILD_TUI=OFF
+set(CMAKE_SYSTEM_NAME Windows)
+set(CMAKE_SYSTEM_PROCESSOR x86_64)
+
+set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)
+set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)
+set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)
+
+get_filename_component(_AGENT_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
+set(_MINGW_PREFIX "${_AGENT_ROOT}/third_party/prefix-mingw")
+list(APPEND CMAKE_PREFIX_PATH "${_MINGW_PREFIX}")
+set(CMAKE_FIND_ROOT_PATH "/usr/x86_64-w64-mingw32" "${_MINGW_PREFIX}")
+set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
+set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY BOTH)
+set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH)
+set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE BOTH)
+
+set(PDCurses_ROOT "${_MINGW_PREFIX}" CACHE PATH "PDCurses prefix")
diff --git a/docs/README-windows.md b/docs/README-windows.md
new file mode 100644
index 0000000..a4e6a3c
--- /dev/null
+++ b/docs/README-windows.md
@@ -0,0 +1,53 @@
+# CST-Agent on Windows
+
+## Release layout
+
+```
+cst-agent.exe
+busybox.exe # built from busybox-w32
+libwinpthread-1.dll # MinGW runtime (required)
+LICENSE.busybox
+README-windows.md
+```
+
+Prebuilt tree from Linux cross-compile: `agent/dist/windows-x86_64/`.
+
+## Build (MinGW-w64 cross from Linux)
+
+```bash
+cd agent
+# 1) third-party: curl (Schannel) + PDCurses + busybox-w32
+./scripts/build_third_party_mingw.sh all
+# sources:
+# https://github.com/rmyorston/busybox-w32.git
+# https://github.com/curl/curl.git
+# https://github.com/wmcbrine/PDCurses.git
+# Unix busybox (optional, Linux/Termux bundle):
+# ./scripts/build_third_party_mingw.sh busybox-unix
+# https://github.com/mirror/busybox.git
+
+# 2) headless
+cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
+ -DCST_BUILD_TUI=OFF -DCMAKE_PREFIX_PATH=$PWD/third_party/prefix-mingw
+cmake --build build-win
+
+# 3) TUI (PDCurses)
+cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
+ -DCST_BUILD_TUI=ON -DCMAKE_PREFIX_PATH=$PWD/third_party/prefix-mingw \
+ -DPDCurses_ROOT=$PWD/third_party/prefix-mingw
+cmake --build build-win-tui
+```
+
+Copy `build-win-tui/cst-agent.exe`, `third_party/busybox/busybox.exe`, and
+`libwinpthread-1.dll` into one folder.
+
+## Run
+
+```bat
+set CST_API_KEY=...
+cst-agent.exe --headless --workspace . --prompt "echo hi"
+cst-agent.exe --workspace .
+```
+
+Shell: bundled `busybox.exe sh -c ...` unless `CST_SHELL` is set.
+Credentials: `%APPDATA%\cst-agent\credentials` (or console prompt).
diff --git a/docs/env_openai.md b/docs/env_openai.md
new file mode 100644
index 0000000..a85879b
--- /dev/null
+++ b/docs/env_openai.md
@@ -0,0 +1,73 @@
+# OpenAI 兼容 API 环境变量
+
+CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completions`),
+可对接官方 OpenAI、MiniMax、以及其它兼容网关。
+
+## 必填 / 常用
+
+| 变量 | 别名 | 说明 | 示例 |
+|---|---|---|---|
+| `CST_API_KEY` | `OPENAI_API_KEY`、`MINIMAX_API_KEY` | API Key(按此优先级读取) | `sk-...` |
+| `CST_API_BASE` | `OPENAI_BASE_URL`、`OPENAI_API_BASE` | API 根路径,须含 `/v1`(不要带 `/chat/completions`) | `https://api.openai.com/v1` |
+| `CST_MODEL` | `OPENAI_MODEL` | 模型名 | `gpt-4.1-mini` / `MiniMax-M2.7` |
+
+未设置 key 时:读配置文件 →(Linux)zenity / 控制台输入。
+
+配置文件:
+- Linux/Termux:`~/.config/cst-agent/credentials`
+- Windows:`%APPDATA%\cst-agent\credentials`
+
+## 示例
+
+### OpenAI 官方
+
+```bash
+export OPENAI_API_KEY='sk-...'
+export OPENAI_BASE_URL='https://api.openai.com/v1'
+export OPENAI_MODEL='gpt-4.1-mini'
+./cst-agent --workspace .
+```
+
+等价 CST 前缀:
+
+```bash
+export CST_API_KEY='sk-...'
+export CST_API_BASE='https://api.openai.com/v1'
+export CST_MODEL='gpt-4.1-mini'
+```
+
+### MiniMax(默认)
+
+```bash
+export CST_API_KEY='...' # 或 MINIMAX_API_KEY
+# 默认 base=https://api.minimax.cn/v1 model=MiniMax-M2.7
+./cst-agent --workspace .
+```
+
+### 任意兼容网关
+
+```bash
+export OPENAI_API_KEY='...'
+export OPENAI_BASE_URL='https://your-gateway.example/v1'
+export OPENAI_MODEL='your-model'
+./cst-agent --headless --prompt 'ping' --workspace .
+```
+
+命令行也可覆盖:`--api-base` / `--api-key` / `--model`。
+
+## 其它环境变量
+
+| 变量 | 说明 |
+|---|---|
+| `CST_MODELS` | `/model` 可选列表,`:` 或 `,` 分隔 |
+| `CST_AUTO_APPROVE=1` | 自动允许 shell/写文件(headless 常用) |
+| `CST_SHELL` | shell 解释器路径(覆盖 busybox/`sh`) |
+| `CST_EXE_DIR` | 可执行文件目录(Termux 旁路 busybox 时有用) |
+
+## CLI 对照
+
+```text
+--api-base URL ← CST_API_BASE / OPENAI_BASE_URL
+--api-key KEY ← CST_API_KEY / OPENAI_API_KEY
+--model NAME ← CST_MODEL / OPENAI_MODEL
+```
diff --git a/docs/eval_tool_cst.md b/docs/eval_tool_cst.md
new file mode 100644
index 0000000..7ce5ec1
--- /dev/null
+++ b/docs/eval_tool_cst.md
@@ -0,0 +1,73 @@
+# 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)。
+
+## 命题分工
+
+| 层 | 主张 | 主变量 | 非主张 |
+|---|---|---|---|
+| 论文 CST | \(O_{\mathrm{conf}}\) 有界确认 → HTML | \(\mathcal{L}_0\) vs 引用式链接等 | — |
+| Tool-loop CST | 同构到工具帧 | **CTL vs JSON-buffer** 截断 | 网关空答率、模型智商 |
+| 工程载体 | OpenAI `tool_calls` + XML fallback | 可演示端到端 | 不算进 CST 正确性定理 |
+
+## 实验 A — 饥饿 / 截断(已自动化)
+
+```bash
+cd agent && make check
+# 关注 ./tests/test_replay_starvation
+```
+
+填写模板(跑完后更新数字):
+
+| 指标 | CTL | JSON-buffer |
+|---|---|---|
+| 完整输入可执行 | yes | yes |
+| 截断点上过早执行次数 | **0** | n/a(截断即失败) |
+| 安全截断样本(本机 `make check`) | 3 | 3 次 trunc failure |
+
+对应理论:裸 JSON ∉ \(\mathcal{L}_0^{\mathrm{tool}}\);CTL 长度前缀使参数体字节可确认,pending 有界。
+
+## 实验 B — mini_calc 端到端案例(不可逆发射)
+
+协议见 [fixtures/mini_calc/](../fixtures/mini_calc/) 与 [scripts/e2e_mini_calc.sh](../scripts/e2e_mini_calc.sh)。
+
+```bash
+cd agent
+./scripts/e2e_mini_calc.sh --baseline-only # 无 key:证明 fixture 初始 RED
+./scripts/e2e_mini_calc.sh # 有 key:agent 修 bug → make test
+```
+
+### 验收口径
+
+| outcome | 含义 |
+|---|---|
+| `pass` | `make test` 绿 |
+| `partial` | 改了 `calc.c` 但测试仍红 |
+| `fail` | 未改文件或 agent/API 失败 |
+| `baseline_ok_agent_skipped` / `baseline_ok_no_api_key` | 仅验证夹具 |
+
+### 论文可引用的叙事
+
+1. Session JSONL 中顺序:assistant `tool_calls` → `role:tool` 结果 →(可选)写文件/shell → 最终文本。
+2. 工具仅在确认/原生完整 arguments 后执行(工程桥);**CTL 饥饿实验**仍是形式主证据。
+3. 案例展示「确认后不可撤回的磁盘副作用」与 transcript 只追加。
+
+### 跑分记录(模板)
+
+| 日期 (UTC) | model | outcome | notes |
+|---|---|---|---|
+| 2026-09-07 | MiniMax-M2.7 | **pass** | `e2e_mini_calc.sh`;baseline RED → agent 修 `add`/`div_ints` → `make test` 绿;见 `out/result-20260907T131300Z.txt` |
+
+## 与主论文的衔接建议
+
+- **附录 / 扩展节标题示例**:「Tool-loop CST:确认前沿到工具调用」
+- 粘贴:theory 表(theory_tool_cst.md)+ 实验 A 表 + 实验 B 一行案例。
+- 明确声明:主 fan-out / \(\mathcal{A}=0\) 数字仍来自 Sashidown;本笔记只提供 **同构第二实例**。
+
+## 明确不写进定理的内容
+
+- 商业 API 空回复、权限 UX、TUI 观感
+- 「已达生产 coding agent」类宣称
diff --git a/docs/theory_tool_cst.md b/docs/theory_tool_cst.md
new file mode 100644
index 0000000..626a7d1
--- /dev/null
+++ b/docs/theory_tool_cst.md
@@ -0,0 +1,60 @@
+# Tool-loop CST:确认前沿推广到工具调用
+
+## 命题
+
+在 agent 工具环中,对模型输出做**确认流式转导**:只在 \(O_{\mathrm{conf}}\) 内不可逆地发射「可执行工具调用 / 可应用补丁 / 可提交会话状态」。相对「拼到合法 JSON 再执行」的缓冲基线,这能降低注意力饥渴导致的残缺工具帧(未闭合括号、半截 arguments)被过早执行或整轮失败的问题。
+
+## 与论文 CST 的对应
+
+| 论文(Markdown→HTML) | 工具环推广 |
+|---|---|
+| 输入字母表 \(\Sigma\) | 模型文本 / CTL 字节流 |
+| 指称 \(f,g\) → HTML | \(f_{\mathrm{tool}}\) → 已确认 `ToolCall` / `PatchHunk` |
+| \(O_{\mathrm{conf}}\) 不可撤回 HTML | 不可撤回:开始执行工具、应用 hunk、写入 transcript |
+| \(\mathcal{L}_0\) 排除引用式链接 | \(\mathcal{L}_0^{\mathrm{tool}}\) **排除裸 JSON 对象** |
+| \(K=2L_{\mathrm{line}}\) | 工具帧同样要求有界 pending |
+
+## 为何排除裸 JSON
+
+JSON 对象依赖括号匹配与字符串转义。在未闭合 `"` / `{` 时,任意后缀都可能改变已见结构的指称,故 \(O_{\mathrm{conf}}\) 往往停在空前缀,pending 随流长增长——这与论文中引用式链接的**无界后向绑定**同构。「等整个 JSON `Parse` 成功」只是 EOF 缓冲,不是有界 CST。
+
+## \(\mathcal{L}_0^{\mathrm{tool}}\):CTL(CST Tool Language)
+
+行界 \(L_{\mathrm{line}}\);参数体用长度前缀,避免转义歧义。
+
+```text
+call \n
+arg \n
+
+end\n
+```
+
+### 确认规则
+
+1. **`call` 行以 `\n` 结束**:确认 `(id, name)`(此后不可撤回工具身份)。
+2. **`arg` 头以 `\n` 结束**:确认 `(key, nbytes)`;随后精确消费 `nbytes` 字节,每字节到达即可确认(无 JSON 转义)。
+3. **`end\n`**:确认整次调用可执行;转导器发射一个 `ToolCall` 事件。
+4. 行长 \(> L_{\mathrm{line}}\)、非法 token、`nbytes` 超上限、非 CTL 噪声 → 离开契约,标记 `tool_parse_error`。
+
+### 有界 pending \(K\)
+
+- 解析行头时 pending \(\le L_{\mathrm{line}}\)。
+- 读参数体时 pending \(\le\) 剩余未读字节数,但**已读体字节已确认**,工作区只需当前行缓冲 + 当前 arg 剩余计数(\(O(L_{\mathrm{line}})\) 寄存器),不随会话总长增长。
+
+取 \(K = L_{\mathrm{line}}\)(行头)与实现常量 `CTL_MAX_ARG`(单参上限,契约拒绝更大值)。
+
+## 不可逆发射
+
+实现是确认忠实的,当且仅当:
+
+- 拼接发射的工具事件序列是 \(O_{\mathrm{conf}}(u)\) 的前缀;
+- 一旦 `ToolCall` 被发射,runtime **必须**执行(或显式记录拒绝),不得因后续字节撤回;
+- session transcript 只追加:用户消息、已确认助手文本、工具调用与结果。
+
+## 对照基线
+
+**JSON-buffer**:将模型输出拼接到可 `cJSON_Parse` 的 `{"name","arguments"}`(或 OpenAI `tool_calls` 形)才执行;流中途截断则失败。与 CTL 使用同一截断点集合,只换帧语法。
+
+## 补丁确认
+
+`apply_patch` 的 patch 文本作为 CTL 的一个 `arg`;应用侧再要求 unified diff 的每个 hunk 完整(`@@` 到下一块或 EOF)才写盘——整 hunk 确认,禁止半截应用。
diff --git a/docs/windows_selftest.md b/docs/windows_selftest.md
new file mode 100644
index 0000000..d84a998
--- /dev/null
+++ b/docs/windows_selftest.md
@@ -0,0 +1,11 @@
+# Windows self-test checklist
+
+Run on a Windows machine (or wine) after MinGW build + busybox.exe beside the binary.
+
+1. `cst-agent.exe --help` exits 0
+2. `set CST_API_KEY=...` then `--headless --workspace . --prompt "run echo hello"` — tool output contains hello
+3. `write_file` under workspace succeeds; path outside workspace rejected
+4. Credentials saved under `%APPDATA%\cst-agent\credentials`
+5. With PDCurses build: launch without `--headless`, confirm TUI draws, `/help` works, Esc cancels
+
+Shell must resolve to bundled busybox unless `CST_SHELL` is set.
diff --git a/fixtures/mini_calc/Makefile b/fixtures/mini_calc/Makefile
new file mode 100644
index 0000000..6c2ed3d
--- /dev/null
+++ b/fixtures/mini_calc/Makefile
@@ -0,0 +1,15 @@
+CC ?= gcc
+CFLAGS ?= -std=c11 -Wall -Wextra -O2
+
+.PHONY: all clean test
+
+all: calc
+
+calc: calc.c
+ $(CC) $(CFLAGS) -o $@ $<
+
+test: calc
+ ./tests/smoke.sh
+
+clean:
+ rm -f calc
diff --git a/fixtures/mini_calc/README.md b/fixtures/mini_calc/README.md
new file mode 100644
index 0000000..f12dde7
--- /dev/null
+++ b/fixtures/mini_calc/README.md
@@ -0,0 +1,19 @@
+# mini_calc
+
+Tiny integer calculator CLI for CST-Agent e2e demos.
+
+## Ops
+
+`add` `sub` `mul` `div` — all four work (see `make test`).
+
+```bash
+./calc add 2 3 # → 5
+./calc div 8 2 # → 4
+```
+
+## Build
+
+```bash
+make
+make test
+```
diff --git a/fixtures/mini_calc/TASK.md b/fixtures/mini_calc/TASK.md
new file mode 100644
index 0000000..009f550
--- /dev/null
+++ b/fixtures/mini_calc/TASK.md
@@ -0,0 +1,11 @@
+# 任务:修好 mini_calc
+
+你在一个很小的 C 项目里。请完成下列事项后结束(不要无关重构):
+
+1. 阅读 `README.md`、`calc.c`、`tests/smoke.sh`。
+2. 修复 `add` 的 off-by-one bug(应为 `a+b`,不是 `a+b+1`)。
+3. 实现真正的整数除法 `div_ints`(`a/b`,调用方已保证 `b!=0`)。
+4. 运行 `make test`,确保全部用例通过。
+5. 用简短中文说明你改了什么。
+
+可用工具:`read_file`、`run_shell`、`write_file` / `apply_patch`。工作区根目录就是本项目。
diff --git a/fixtures/mini_calc/calc.c b/fixtures/mini_calc/calc.c
new file mode 100644
index 0000000..4f15948
--- /dev/null
+++ b/fixtures/mini_calc/calc.c
@@ -0,0 +1,58 @@
+/* mini_calc — intentionally broken fixture for CST-Agent e2e / paper case study.
+ *
+ * Bugs (for the agent to fix):
+ * 1) add(): off-by-one — returns a+b+1
+ * 2) div(): missing — only stub returns 0
+ * README claims all four ops work (false until fixed).
+ */
+#include
+#include
+#include
+
+int add(int a, int b) {
+ /* BUG: off-by-one */
+ return a + b + 1;
+}
+
+int sub(int a, int b) { return a - b; }
+
+int mul(int a, int b) { return a * b; }
+
+int div_ints(int a, int b) {
+ /* BUG: not implemented */
+ (void)a;
+ (void)b;
+ return 0;
+}
+
+static void usage(const char *argv0) {
+ fprintf(stderr, "Usage: %s \n", argv0);
+}
+
+int main(int argc, char **argv) {
+ if (argc != 4) {
+ usage(argv[0]);
+ return 2;
+ }
+ int a = atoi(argv[2]);
+ int b = atoi(argv[3]);
+ int r = 0;
+ if (strcmp(argv[1], "add") == 0)
+ r = add(a, b);
+ else if (strcmp(argv[1], "sub") == 0)
+ r = sub(a, b);
+ else if (strcmp(argv[1], "mul") == 0)
+ r = mul(a, b);
+ else if (strcmp(argv[1], "div") == 0) {
+ if (b == 0) {
+ fprintf(stderr, "div by zero\n");
+ return 3;
+ }
+ r = div_ints(a, b);
+ } else {
+ usage(argv[0]);
+ return 2;
+ }
+ printf("%d\n", r);
+ return 0;
+}
diff --git a/fixtures/mini_calc/tests/smoke.sh b/fixtures/mini_calc/tests/smoke.sh
new file mode 100755
index 0000000..f403ae5
--- /dev/null
+++ b/fixtures/mini_calc/tests/smoke.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# Smoke tests for mini_calc. Exit 0 only when add/sub/mul/div are correct.
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+CALC="$ROOT/calc"
+fail=0
+
+expect() {
+ local op=$1 a=$2 b=$3 want=$4
+ local got
+ got="$("$CALC" "$op" "$a" "$b")"
+ if [[ "$got" != "$want" ]]; then
+ echo "FAIL: $op $a $b → got '$got' want '$want'"
+ fail=1
+ else
+ echo "ok: $op $a $b → $got"
+ fi
+}
+
+expect add 2 3 5
+expect sub 9 4 5
+expect mul 3 4 12
+expect div 8 2 4
+
+if [[ "$fail" -ne 0 ]]; then
+ echo "smoke: FAILED"
+ exit 1
+fi
+echo "smoke: PASSED"
+exit 0
diff --git a/fixtures/snake_cpp/Makefile b/fixtures/snake_cpp/Makefile
new file mode 100644
index 0000000..1ddc5ab
--- /dev/null
+++ b/fixtures/snake_cpp/Makefile
@@ -0,0 +1,10 @@
+CXX = g++
+CXXFLAGS = -O2 -std=c++17 -Wall -Wextra
+
+snake: snake.cpp
+ $(CXX) $(CXXFLAGS) -o snake snake.cpp
+
+clean:
+ rm -f snake
+
+.PHONY: clean
diff --git a/fixtures/snake_cpp/README.md b/fixtures/snake_cpp/README.md
new file mode 100644
index 0000000..eb572e6
--- /dev/null
+++ b/fixtures/snake_cpp/README.md
@@ -0,0 +1,51 @@
+# Snake Game
+
+A classic terminal-based Snake game written in C++17.
+
+## Features
+
+- Smooth snake movement using POSIX terminal I/O
+- WASD or Arrow keys for movement
+- Food spawning and snake growth
+- Wall and self-collision detection
+- Score tracking
+- Pause functionality (P key)
+- Clean terminal rendering
+
+## Build
+
+```bash
+make
+```
+
+This compiles `snake.cpp` with optimization level O2 and C++17 standard.
+
+## Run
+
+```bash
+./snake
+```
+
+## Controls
+
+| Key | Action |
+|-----|--------|
+| W / ↑ | Move Up |
+| S / ↓ | Move Down |
+| A / ← | Move Left |
+| D / → | Move Right |
+| P | Pause/Resume |
+| Q | Quit |
+
+## Game Rules
+
+- Eat the `*` food to grow and score points (+10 per food)
+- Avoid hitting the walls `#`
+- Avoid colliding with your own body `o`
+- The snake moves continuously
+
+## Requirements
+
+- GCC/G++ with C++17 support
+- POSIX-compliant system (Linux, macOS)
+- Terminal with ANSI escape support
diff --git a/fixtures/snake_cpp/TASK.md b/fixtures/snake_cpp/TASK.md
new file mode 100644
index 0000000..efe5cad
--- /dev/null
+++ b/fixtures/snake_cpp/TASK.md
@@ -0,0 +1,10 @@
+Write a complete C++ terminal Snake game in this workspace.
+
+Requirements:
+1. Single file `snake.cpp` (C++17 ok). Use POSIX terminal I/O: raw mode or ncurses; WASD or arrow keys; quit with `q`.
+2. Features: moving snake, food, grow on eat, wall or self collision ends game, score printed, board roughly 20x20 or similar.
+3. `Makefile` with target `snake` that runs `g++ -O2 -o snake snake.cpp` (link `-lncurses` only if you use ncurses).
+4. After writing, run `make` to verify it compiles. Do not need an interactive play session.
+5. Brief README.md with how to build and run.
+
+Use write_file / run_shell tools. Prefer implementing yourself rather than downloading.
diff --git a/fixtures/snake_cpp/snake.cpp b/fixtures/snake_cpp/snake.cpp
new file mode 100644
index 0000000..5eb3d81
--- /dev/null
+++ b/fixtures/snake_cpp/snake.cpp
@@ -0,0 +1,224 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// Game constants
+constexpr int BOARD_WIDTH = 20;
+constexpr int BOARD_HEIGHT = 15;
+constexpr char WALL = '#';
+constexpr char SNAKE_BODY = 'o';
+constexpr char SNAKE_HEAD = 'O';
+constexpr char FOOD = '*';
+constexpr char EMPTY = ' ';
+
+// Directions
+enum class Direction { UP, DOWN, LEFT, RIGHT };
+
+struct Position {
+ int x, y;
+ bool operator==(const Position& other) const {
+ return x == other.x && y == other.y;
+ }
+};
+
+struct GameState {
+ std::deque snake;
+ Position food;
+ Direction dir;
+ Direction next_dir;
+ int score;
+ bool game_over;
+ bool paused;
+};
+
+char board[BOARD_HEIGHT][BOARD_WIDTH + 1];
+
+void place_food(GameState& state);
+
+void init_board() {
+ for (int y = 0; y < BOARD_HEIGHT; y++) {
+ for (int x = 0; x < BOARD_WIDTH; x++) {
+ if (y == 0 || y == BOARD_HEIGHT - 1 || x == 0 || x == BOARD_WIDTH - 1) {
+ board[y][x] = WALL;
+ } else {
+ board[y][x] = EMPTY;
+ }
+ }
+ board[y][BOARD_WIDTH] = '\0';
+ }
+}
+
+void init_game(GameState& state) {
+ Position head = {BOARD_WIDTH / 2, BOARD_HEIGHT / 2};
+ state.snake = {head, {head.x - 1, head.y}, {head.x - 2, head.y}};
+ state.dir = Direction::RIGHT;
+ state.next_dir = Direction::RIGHT;
+ state.score = 0;
+ state.game_over = false;
+ state.paused = false;
+ place_food(state);
+}
+
+void place_food(GameState& state) {
+ Position pos;
+ bool on_snake;
+ do {
+ on_snake = false;
+ pos.x = 1 + std::rand() % (BOARD_WIDTH - 2);
+ pos.y = 1 + std::rand() % (BOARD_HEIGHT - 2);
+ for (const auto& segment : state.snake) {
+ if (segment == pos) {
+ on_snake = true;
+ break;
+ }
+ }
+ } while (on_snake);
+ state.food = pos;
+}
+
+void render(const GameState& state) {
+ system("cls"); // Windows clear screen
+
+ init_board();
+
+ board[state.food.y][state.food.x] = FOOD;
+
+ for (size_t i = 0; i < state.snake.size(); i++) {
+ const auto& segment = state.snake[i];
+ board[segment.y][segment.x] = (i == 0) ? SNAKE_HEAD : SNAKE_BODY;
+ }
+
+ std::cout << "=== SNAKE GAME ===\n";
+ std::cout << "Score: " << state.score << "\n";
+ std::cout << "Controls: WASD/Arrows to move, Q to quit, P to pause\n";
+ std::cout << "==================\n";
+
+ for (int y = 0; y < BOARD_HEIGHT; y++) {
+ std::cout << board[y] << "\n";
+ }
+
+ if (state.game_over) {
+ std::cout << "\n=== GAME OVER! ===\n";
+ std::cout << "Final Score: " << state.score << "\n";
+ std::cout << "Press R to restart or Q to quit.\n";
+ } else if (state.paused) {
+ std::cout << "\n=== PAUSED ===\n";
+ std::cout << "Press P to continue.\n";
+ }
+}
+
+bool kbhit() {
+ return _kbhit() != 0;
+}
+
+char get_char() {
+ return _getch();
+}
+
+void update_direction(GameState& state, Direction new_dir) {
+ Direction opposite = Direction::UP;
+ switch (state.dir) {
+ case Direction::UP: opposite = Direction::DOWN; break;
+ case Direction::DOWN: opposite = Direction::UP; break;
+ case Direction::LEFT: opposite = Direction::RIGHT; break;
+ case Direction::RIGHT: opposite = Direction::LEFT; break;
+ }
+ if (new_dir != opposite) {
+ state.next_dir = new_dir;
+ }
+}
+
+bool move_snake(GameState& state) {
+ state.dir = state.next_dir;
+
+ Position head = state.snake.front();
+ switch (state.dir) {
+ case Direction::UP: head.y--; break;
+ case Direction::DOWN: head.y++; break;
+ case Direction::LEFT: head.x--; break;
+ case Direction::RIGHT: head.x++; break;
+ }
+
+ // Check wall collision
+ if (head.x <= 0 || head.x >= BOARD_WIDTH - 1 ||
+ head.y <= 0 || head.y >= BOARD_HEIGHT - 1) {
+ return false;
+ }
+
+ // Check self collision (skip tail as it will move)
+ for (size_t i = 0; i < state.snake.size() - 1; i++) {
+ if (state.snake[i] == head) {
+ return false;
+ }
+ }
+
+ state.snake.push_front(head);
+
+ // Check food
+ if (head == state.food) {
+ state.score += 10;
+ place_food(state);
+ } else {
+ state.snake.pop_back();
+ }
+
+ return true;
+}
+
+void handle_input(GameState& state) {
+ if (kbhit()) {
+ char c = get_char();
+
+ if (state.game_over) {
+ if (c == 'r' || c == 'R') {
+ init_game(state);
+ }
+ return;
+ }
+
+ if (state.paused) {
+ if (c == 'p' || c == 'P') {
+ state.paused = false;
+ }
+ return;
+ }
+
+ switch (c) {
+ case 'w': case 'W': case 72: update_direction(state, Direction::UP); break;
+ case 's': case 'S': case 80: update_direction(state, Direction::DOWN); break;
+ case 'a': case 'A': case 75: update_direction(state, Direction::LEFT); break;
+ case 'd': case 'D': case 77: update_direction(state, Direction::RIGHT); break;
+ case 'q': case 'Q': state.game_over = true; state.score = -1; break;
+ case 'p': case 'P': state.paused = true; break;
+ }
+ }
+}
+
+int main() {
+ std::srand(static_cast(std::time(nullptr)));
+
+ GameState state;
+ init_game(state);
+
+ while (true) {
+ render(state);
+
+ if (!state.game_over && !state.paused) {
+ if (!move_snake(state)) {
+ state.game_over = true;
+ }
+ }
+
+ handle_input();
+
+ std::this_thread::sleep_for(std::chrono::milliseconds(100));
+ }
+
+ return 0;
+}
diff --git a/include/cst_agent.h b/include/cst_agent.h
new file mode 100644
index 0000000..1010516
--- /dev/null
+++ b/include/cst_agent.h
@@ -0,0 +1,174 @@
+#ifndef CST_AGENT_H
+#define CST_AGENT_H
+
+#include "ctl.h"
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define CST_AGENT_MAX_MSG 256
+#define CST_AGENT_MAX_CONTENT (512 * 1024)
+#define CST_AGENT_CTX_WINDOW 40 /* max non-system msgs sent to LLM */
+
+typedef enum {
+ CST_ROLE_SYSTEM = 0,
+ CST_ROLE_USER,
+ CST_ROLE_ASSISTANT,
+ CST_ROLE_TOOL
+} cst_role_t;
+
+typedef struct {
+ cst_role_t role;
+ char *content;
+ char tool_call_id[CTL_MAX_ID]; /* for tool results */
+ char tool_name[CTL_MAX_NAME];
+ char *tool_calls_json; /* assistant: OpenAI tool_calls array JSON, or NULL */
+} cst_message_t;
+
+typedef struct {
+ char workspace_root[4096];
+ char session_path[4096];
+ char api_base[512];
+ char api_key[512];
+ char model[128];
+ int headless;
+ int confirm_shell; /* confirm run_shell */
+ int confirm_write; /* confirm write_file / apply_patch */
+ int auto_approve; /* headless / CST_AUTO_APPROVE=1 */
+} cst_agent_config_t;
+
+typedef struct cst_session cst_session_t;
+typedef struct cst_agent cst_agent_t;
+
+/* --- sandbox --- */
+int cst_sandbox_resolve(const char *workspace_root, const char *rel_or_abs,
+ char *out, size_t out_sz);
+
+/* --- session (append-only JSONL) --- */
+cst_session_t *cst_session_open(const char *path);
+void cst_session_close(cst_session_t *s);
+int cst_session_append(cst_session_t *s, const cst_message_t *msg);
+int cst_session_count(const cst_session_t *s);
+const cst_message_t *cst_session_get(const cst_session_t *s, int i);
+const char *cst_session_path(const cst_session_t *s);
+int cst_session_clear(cst_session_t *s, int keep_system);
+int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n);
+
+/* --- workspace .agent/sessions store (OpenCode-like) --- */
+int cst_agent_store_ensure(const char *workspace);
+int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz);
+int cst_agent_store_new_id(char *id_out, size_t id_sz);
+int cst_agent_store_path_for_id(const char *workspace, const char *id,
+ char *out, size_t out_sz);
+int cst_agent_store_set_current(const char *workspace, const char *id);
+int cst_agent_store_get_current(const char *workspace, char *id_out,
+ size_t id_sz);
+int cst_agent_store_resolve_default(const char *workspace, char *path_out,
+ size_t path_sz, char *id_out,
+ size_t id_sz);
+int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids);
+
+/* --- tools --- */
+typedef struct {
+ char *out;
+ size_t out_len;
+ int exit_code;
+} cst_tool_result_t;
+
+void cst_tool_result_free(cst_tool_result_t *r);
+
+int cst_tool_read_file(const char *workspace_root, const ctl_tool_call_t *call,
+ cst_tool_result_t *r);
+int cst_tool_run_shell(const char *workspace_root, const ctl_tool_call_t *call,
+ cst_tool_result_t *r);
+int cst_tool_apply_patch(const char *workspace_root, const ctl_tool_call_t *call,
+ cst_tool_result_t *r);
+int cst_tool_write_file(const char *workspace_root, const ctl_tool_call_t *call,
+ cst_tool_result_t *r);
+int cst_tool_dispatch(const char *workspace_root, const ctl_tool_call_t *call,
+ cst_tool_result_t *r);
+
+/* Build ctl_tool_call from native OpenAI tool_call fields. */
+int cst_tool_call_from_native(ctl_tool_call_t *out, const char *id,
+ const char *name, const char *args_json);
+
+/* --- LLM --- */
+typedef void (*cst_llm_on_delta_fn)(const char *data, size_t len, void *ud);
+typedef void (*cst_llm_on_native_tool_fn)(const char *id, const char *name,
+ const char *args_json, void *ud);
+
+typedef struct {
+ const char *api_base;
+ const char *api_key;
+ const char *model;
+ const cst_message_t *msgs;
+ int nmsgs;
+ cst_llm_on_delta_fn on_delta;
+ cst_llm_on_native_tool_fn on_native_tool; /* fired once per completed call */
+ void *userdata;
+ int enable_tools; /* send tools[] schema */
+ int tool_choice_none; /* "tool_choice":"none" */
+ volatile int *cancel_flag; /* non-zero → abort curl */
+} cst_llm_request_t;
+
+int cst_llm_chat_stream(const cst_llm_request_t *req, char *err, size_t err_sz);
+
+/* Live UI / observability hooks for a turn */
+typedef struct {
+ void (*on_status)(const char *msg, void *ud);
+ void (*on_assistant_delta)(const char *text, size_t len, void *ud);
+ void (*on_tool_event)(const char *phase, const ctl_tool_call_t *call,
+ const char *detail, void *ud);
+ void (*on_think_delta)(const char *text, size_t len, void *ud);
+ /* Return 1=allow, 0=deny. NULL → use config auto_approve / defaults. */
+ int (*on_permission)(const char *tool_name, const char *detail, void *ud);
+ void *userdata;
+} cst_agent_hooks_t;
+
+/* --- agent loop --- */
+cst_agent_t *cst_agent_create(const cst_agent_config_t *cfg);
+void cst_agent_destroy(cst_agent_t *a);
+int cst_agent_run_turn(cst_agent_t *a, const char *user_text, char *err,
+ size_t err_sz);
+int cst_agent_run_turn_ex(cst_agent_t *a, const char *user_text,
+ const cst_agent_hooks_t *hooks, char *err,
+ size_t err_sz);
+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_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);
+int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz);
+
+const char *cst_agent_system_prompt(void);
+
+/* Credentials: zenity + ~/.config/cst-agent/credentials (0600) */
+int cst_creds_load_api_key(char *out, size_t out_sz);
+int cst_creds_save_api_key(const char *key);
+int cst_creds_prompt_api_key(char *out, size_t out_sz);
+int cst_creds_ensure_api_key(char *out, size_t out_sz);
+
+/* JSON baseline parser for experiments (buffer until valid object). */
+typedef struct {
+ int ok;
+ char name[CTL_MAX_NAME];
+ char arguments_json[65536];
+} cst_json_tool_t;
+
+int cst_json_try_parse_tool(const char *buf, size_t len, cst_json_tool_t *out);
+
+/* Built-in / CST_MODELS model picker list. Writes up to max_out names.
+ * Always includes `current` if non-empty and not already listed. */
+int cst_models_list(const char *current, char out[][128], int max_out);
+
+int cst_tui_run(cst_agent_t *agent);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* CST_AGENT_H */
diff --git a/include/ctl.h b/include/ctl.h
new file mode 100644
index 0000000..8bd9b72
--- /dev/null
+++ b/include/ctl.h
@@ -0,0 +1,98 @@
+#ifndef CTL_H
+#define CTL_H
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#ifndef CTL_MAX_LINE
+#define CTL_MAX_LINE 4096
+#endif
+
+#ifndef CTL_MAX_ARGS
+#define CTL_MAX_ARGS 16
+#endif
+
+#ifndef CTL_MAX_ARG_BYTES
+#define CTL_MAX_ARG_BYTES (1024 * 1024)
+#endif
+
+#ifndef CTL_MAX_ID
+#define CTL_MAX_ID 64
+#endif
+
+#ifndef CTL_MAX_NAME
+#define CTL_MAX_NAME 64
+#endif
+
+#ifndef CTL_MAX_KEY
+#define CTL_MAX_KEY 64
+#endif
+
+typedef enum {
+ CTL_OK = 0,
+ CTL_ERR_CONTRACT = 1, /* left L0_tool */
+ CTL_ERR_NOMEM = 2,
+ CTL_ERR_LIMIT = 3
+} ctl_status_t;
+
+typedef enum {
+ CTL_EV_NONE = 0,
+ CTL_EV_CALL_OPEN, /* id+name confirmed */
+ CTL_EV_ARG, /* one arg fully confirmed */
+ CTL_EV_CALL_READY, /* end confirmed — safe to execute */
+ CTL_EV_TEXT, /* non-CTL confirmed text (outside call) */
+ CTL_EV_ERROR
+} ctl_event_type_t;
+
+typedef struct {
+ char key[CTL_MAX_KEY];
+ char *value;
+ size_t value_len;
+} ctl_arg_t;
+
+typedef struct {
+ char id[CTL_MAX_ID];
+ char name[CTL_MAX_NAME];
+ ctl_arg_t args[CTL_MAX_ARGS];
+ int nargs;
+} ctl_tool_call_t;
+
+typedef struct {
+ ctl_event_type_t type;
+ const ctl_tool_call_t *call; /* valid for CALL_* / ARG */
+ const char *text; /* for TEXT */
+ size_t text_len;
+ const char *error; /* for ERROR */
+} ctl_event_t;
+
+typedef void (*ctl_on_event_fn)(const ctl_event_t *ev, void *userdata);
+
+typedef struct ctl_parser ctl_parser_t;
+
+ctl_parser_t *ctl_parser_create(ctl_on_event_fn on_event, void *userdata);
+void ctl_parser_destroy(ctl_parser_t *p);
+
+/* Feed UTF-8 bytes. Emits only confirmed events. */
+ctl_status_t ctl_parser_feed(ctl_parser_t *p, const char *data, size_t len);
+
+/* EOF: flush; incomplete call => ERROR (no execute). */
+ctl_status_t ctl_parser_finish(ctl_parser_t *p);
+
+/* Bytes currently pending (unconfirmed). */
+size_t ctl_parser_pending(const ctl_parser_t *p);
+
+/* Peak pending observed (for experiments). */
+size_t ctl_parser_pending_peak(const ctl_parser_t *p);
+
+/* Reset for a new assistant turn. */
+void ctl_parser_reset(ctl_parser_t *p);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* CTL_H */
diff --git a/include/mm_tool.h b/include/mm_tool.h
new file mode 100644
index 0000000..5990a7d
--- /dev/null
+++ b/include/mm_tool.h
@@ -0,0 +1,51 @@
+#ifndef MM_TOOL_H
+#define MM_TOOL_H
+
+#include "ctl.h"
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/*
+ * Streaming CST scanner for MiniMax native tool XML:
+ *
+ *
+ *
+ * README.md
+ *
+ *
+ *
+ * Also accepts the dialect seen in the wild:
+ *
+ * invoke read_file
+ * ./README.md
+ *
+ *
+ * Confirmation: nothing executes until .
+ */
+
+typedef void (*mm_on_event_fn)(const ctl_event_t *ev, void *userdata);
+
+typedef struct mm_scan mm_scan_t;
+
+mm_scan_t *mm_scan_create(mm_on_event_fn on_event, void *userdata);
+void mm_scan_destroy(mm_scan_t *s);
+void mm_scan_reset(mm_scan_t *s);
+
+/* Feed bytes. Emits TEXT for confirmed non-tool regions and CALL_* for
+ * completed tool blocks. Incomplete tool blocks stay pending. */
+int mm_scan_feed(mm_scan_t *s, const char *data, size_t len);
+
+/* Flush: incomplete open tool block => emit remaining as TEXT (no execute). */
+int mm_scan_finish(mm_scan_t *s);
+
+size_t mm_scan_pending(const mm_scan_t *s);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/include/os_compat.h b/include/os_compat.h
new file mode 100644
index 0000000..2beb383
--- /dev/null
+++ b/include/os_compat.h
@@ -0,0 +1,68 @@
+#ifndef CST_OS_COMPAT_H
+#define CST_OS_COMPAT_H
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#ifndef OS_PATH_MAX
+#define OS_PATH_MAX 4096
+#endif
+
+/* --- path --- */
+int os_path_is_abs(const char *p);
+/* Join with native separator; out always uses '/' on Unix and '\\' on Win for
+ * the joiner, but os_path_norm_slashes converts to '/' for comparisons. */
+int os_path_join(char *out, size_t out_sz, const char *a, const char *b);
+void os_path_norm_slashes(char *p);
+int os_realpath(const char *path, char *out, size_t out_sz);
+/* True if path is under root (after realpath/norm). Trailing slash rules apply. */
+int os_path_under(const char *root, const char *path);
+
+/* --- dirs / config --- */
+int os_mkdir(const char *path, int mode);
+int os_config_dir(char *out, size_t out_sz);
+int os_temp_dir(char *out, size_t out_sz);
+/* Create unique temp file; writes path into out; returns fd or -1. Caller closes. */
+int os_mkstemp(char *out, size_t out_sz, const char *prefix);
+
+/* Directory containing the running executable (for bundled busybox).
+ * Call os_set_prog_path(argv[0]) early on Unix/Termux as fallback. */
+void os_set_prog_path(const char *argv0);
+int os_exe_dir(char *out, size_t out_sz);
+
+/* Resolve shell interpreter path into out.
+ * Order: CST_SHELL → /busybox[.exe] → /bin/sh (Unix). */
+int os_resolve_shell(char *out, size_t out_sz);
+
+/* --- process --- */
+typedef struct {
+ char *out;
+ size_t out_len;
+ int exit_code;
+} os_run_result_t;
+
+void os_run_result_free(os_run_result_t *r);
+
+/* Run argv[0..] (NULL-terminated) with cwd; capture stdout+stderr.
+ * max_out caps captured bytes (0 = default 512KiB). */
+int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
+ os_run_result_t *r);
+
+/* Convenience: shell -c cmd via os_resolve_shell. */
+int os_run_shell_c(const char *cwd, const char *cmd, size_t max_out,
+ os_run_result_t *r);
+
+/* --- misc --- */
+void os_localtime(const time_t *t, struct tm *out);
+int os_getpid(void);
+int os_chmod_private(const char *path); /* best-effort 0600; no-op/ok on Win */
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* CST_OS_COMPAT_H */
diff --git a/scripts/bootstrap_linux.sh b/scripts/bootstrap_linux.sh
new file mode 100755
index 0000000..7a8dc2e
--- /dev/null
+++ b/scripts/bootstrap_linux.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+# One-shot: clone (optional) + build CST-Agent on Linux.
+set -euo pipefail
+
+REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
+DIR="${1:-}"
+
+if [[ -z "$DIR" ]]; then
+ if [[ -f Makefile && -d src ]]; then
+ DIR="$(pwd)"
+ else
+ DIR="CST-agent"
+ fi
+fi
+
+if [[ ! -f "$DIR/Makefile" ]]; then
+ echo "==> git clone $REPO_URL -> $DIR"
+ git clone "$REPO_URL" "$DIR"
+fi
+cd "$DIR"
+
+echo "==> deps hint: gcc/clang make cmake libcurl ncursesw (zenity optional)"
+if command -v pacman >/dev/null 2>&1; then
+ echo " Arch: sudo pacman -S --needed base-devel cmake curl ncurses"
+elif command -v apt-get >/dev/null 2>&1; then
+ echo " Debian: sudo apt install build-essential cmake libcurl4-openssl-dev libncurses-dev"
+fi
+
+echo "==> make"
+make -j"$(nproc 2>/dev/null || echo 4)"
+make check || true
+
+echo
+echo "OK: ./cst-agent"
+echo "OpenAI-compatible env (see docs/env_openai.md):"
+echo " export OPENAI_API_KEY=sk-..."
+echo " export OPENAI_BASE_URL=https://api.openai.com/v1"
+echo " export OPENAI_MODEL=gpt-4.1-mini"
+echo " ./cst-agent --workspace ."
+echo "Or MiniMax default: export CST_API_KEY=... && ./cst-agent --workspace ."
diff --git a/scripts/bootstrap_termux.sh b/scripts/bootstrap_termux.sh
new file mode 100755
index 0000000..203c82f
--- /dev/null
+++ b/scripts/bootstrap_termux.sh
@@ -0,0 +1,31 @@
+#!/data/data/com.termux/files/usr/bin/bash
+# One-shot: clone + build CST-Agent on Android Termux.
+set -euo pipefail
+
+REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
+DIR="${1:-CST-agent}"
+
+echo "==> Termux packages"
+pkg install -y git clang make cmake libcurl ncurses-ui-libs 2>/dev/null \
+ || pkg install -y git clang make cmake curl ncurses
+
+if [[ ! -f "$DIR/Makefile" ]]; then
+ echo "==> git clone $REPO_URL -> $DIR"
+ git clone "$REPO_URL" "$DIR"
+fi
+cd "$DIR"
+
+echo "==> cmake build (TUI)"
+cmake -B build -DCMAKE_C_COMPILER=clang -DCST_BUILD_TUI=ON
+cmake --build build -j"$(nproc 2>/dev/null || echo 4)"
+# Also keep Makefile binary path convenience
+cp -f build/cst-agent ./cst-agent 2>/dev/null || true
+
+echo
+echo "OK: ./cst-agent (or ./build/cst-agent)"
+echo "OpenAI-compatible:"
+echo " export OPENAI_API_KEY=sk-..."
+echo " export OPENAI_BASE_URL=https://api.openai.com/v1"
+echo " export OPENAI_MODEL=gpt-4.1-mini"
+echo " ./cst-agent --workspace \$HOME"
+echo "Docs: docs/env_openai.md"
diff --git a/scripts/bootstrap_windows.sh b/scripts/bootstrap_windows.sh
new file mode 100755
index 0000000..1e31209
--- /dev/null
+++ b/scripts/bootstrap_windows.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# One-shot on a Linux host: cross-build Windows x86_64 release (MinGW).
+# Output: dist/windows-x86_64/ — copy that folder to a Windows machine.
+set -euo pipefail
+
+REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
+DIR="${1:-}"
+
+if [[ -z "$DIR" ]]; then
+ if [[ -f CMakeLists.txt && -d src ]]; then
+ DIR="$(pwd)"
+ else
+ DIR="CST-agent"
+ fi
+fi
+
+if [[ ! -f "$DIR/CMakeLists.txt" ]]; then
+ echo "==> git clone $REPO_URL -> $DIR"
+ git clone "$REPO_URL" "$DIR"
+fi
+cd "$DIR"
+
+need() { command -v "$1" >/dev/null || { echo "missing $1 (install mingw-w64 gcc cmake git)" >&2; exit 1; }; }
+need git; need cmake; need x86_64-w64-mingw32-gcc
+
+echo "==> third-party (curl Schannel + PDCurses + busybox-w32)"
+chmod +x scripts/*.sh 2>/dev/null || true
+./scripts/build_third_party_mingw.sh all
+
+echo "==> cmake Windows TUI"
+cmake -B build-win-tui \
+ -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
+ -DCST_BUILD_TUI=ON \
+ -DCMAKE_PREFIX_PATH="$PWD/third_party/prefix-mingw" \
+ -DPDCurses_ROOT="$PWD/third_party/prefix-mingw"
+cmake --build build-win-tui -j"$(nproc 2>/dev/null || echo 4)"
+
+echo "==> package"
+./scripts/package_windows.sh
+
+echo
+echo "OK: dist/windows-x86_64/"
+echo "Copy that folder to Windows, then:"
+echo " set OPENAI_API_KEY=sk-..."
+echo " set OPENAI_BASE_URL=https://api.openai.com/v1"
+echo " set OPENAI_MODEL=gpt-4.1-mini"
+echo " cst-agent.exe --workspace ."
+echo "See docs/env_openai.md and docs/README-windows.md"
diff --git a/scripts/build_third_party_mingw.sh b/scripts/build_third_party_mingw.sh
new file mode 100755
index 0000000..f51e05b
--- /dev/null
+++ b/scripts/build_third_party_mingw.sh
@@ -0,0 +1,139 @@
+#!/usr/bin/env bash
+# Build third-party deps for Windows MinGW cross + optional Unix busybox.
+# Sources:
+# busybox-w32: https://github.com/rmyorston/busybox-w32.git
+# busybox: https://github.com/mirror/busybox.git
+# curl: https://github.com/curl/curl.git (Windows via Schannel)
+# PDCurses: https://github.com/wmcbrine/PDCurses.git
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+TP="$ROOT/third_party"
+HOST="${MINGW_HOST:-x86_64-w64-mingw32}"
+JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
+mkdir -p "$TP"
+
+need_cmd() { command -v "$1" >/dev/null || { echo "missing: $1" >&2; exit 1; }; }
+need_cmd git
+need_cmd make
+need_cmd "${HOST}-gcc"
+
+clone_or_update() {
+ local url="$1" dir="$2"
+ if [[ -d "$dir/.git" ]]; then
+ git -C "$dir" fetch --depth 1 origin 2>/dev/null || true
+ git -C "$dir" pull --ff-only 2>/dev/null || true
+ else
+ git clone --depth 1 "$url" "$dir"
+ fi
+}
+
+# --- curl (static, Schannel) ---
+build_curl_mingw() {
+ clone_or_update https://github.com/curl/curl.git "$TP/curl"
+ cd "$TP/curl"
+ if [[ ! -f configure ]]; then
+ autoreconf -fi 2>/dev/null || true
+ fi
+ # Prefer cmake for curl modern tree
+ rm -rf build-mingw
+ cmake -B build-mingw -G "Unix Makefiles" \
+ -DCMAKE_SYSTEM_NAME=Windows \
+ -DCMAKE_C_COMPILER="${HOST}-gcc" \
+ -DCMAKE_RC_COMPILER="${HOST}-windres" \
+ -DCMAKE_INSTALL_PREFIX="$TP/prefix-mingw" \
+ -DBUILD_SHARED_LIBS=OFF \
+ -DCURL_USE_SCHANNEL=ON \
+ -DCURL_USE_OPENSSL=OFF \
+ -DCURL_DISABLE_LDAP=ON \
+ -DCURL_USE_LIBPSL=OFF \
+ -DCURL_ZLIB=OFF \
+ -DCURL_BROTLI=OFF \
+ -DCURL_ZSTD=OFF \
+ -DUSE_NGHTTP2=OFF \
+ -DBUILD_CURL_EXE=OFF \
+ -DBUILD_TESTING=OFF \
+ -DHTTP_ONLY=OFF
+ cmake --build build-mingw -j"$JOBS"
+ cmake --install build-mingw
+ echo "curl installed -> $TP/prefix-mingw"
+}
+
+# --- PDCurses ---
+build_pdcurses_mingw() {
+ clone_or_update https://github.com/wmcbrine/PDCurses.git "$TP/PDCurses"
+ cd "$TP/PDCurses/wincon"
+ make -f Makefile clean 2>/dev/null || true
+ make -f Makefile \
+ CC="${HOST}-gcc" \
+ LINK="${HOST}-gcc" \
+ CFLAGS="-O2 -I.. -DPDC_WIDE -DPDC_FORCE_UTF8" \
+ LIBEXE="${HOST}-ar" \
+ LIBFLAGS=rcv \
+ pdcurses.a
+ mkdir -p "$TP/prefix-mingw/include" "$TP/prefix-mingw/lib"
+ cp -f ../curses.h "$TP/prefix-mingw/include/"
+ [[ -f ../panel.h ]] && cp -f ../panel.h "$TP/prefix-mingw/include/" || true
+ cp -f pdcurses.a "$TP/prefix-mingw/lib/libpdcurses.a"
+ # Also install as curses for find_library
+ cp -f pdcurses.a "$TP/prefix-mingw/lib/libcurses.a"
+ echo "PDCurses -> $TP/prefix-mingw"
+}
+
+# --- busybox-w32 ---
+build_busybox_w32() {
+ clone_or_update https://github.com/rmyorston/busybox-w32.git "$TP/busybox-w32"
+ cd "$TP/busybox-w32"
+ # configs: try mingw64 defconfig
+ if [[ -f configs/mingw64_defconfig ]]; then
+ make mingw64_defconfig
+ elif [[ -f configs/mingw_defconfig ]]; then
+ make mingw_defconfig
+ else
+ make defconfig
+ fi
+ # Cross compile
+ make -j"$JOBS" CROSS_COMPILE="${HOST}-" \
+ CONFIG_EXTRA_CFLAGS="-O2" \
+ busybox.exe 2>&1 || make -j"$JOBS" CROSS_COMPILE="${HOST}-"
+ mkdir -p "$TP/busybox"
+ if [[ -f busybox.exe ]]; then
+ cp -f busybox.exe "$TP/busybox/busybox.exe"
+ else
+ find . -name 'busybox.exe' | head -1 | xargs -I{} cp -f {} "$TP/busybox/busybox.exe"
+ fi
+ cat > "$TP/busybox/LICENSE.busybox" << 'EOF'
+BusyBox is GPLv2. Source: https://github.com/rmyorston/busybox-w32
+Upstream BusyBox: https://github.com/mirror/busybox
+EOF
+ ls -la "$TP/busybox/busybox.exe"
+}
+
+# --- Unix busybox (optional, for Linux/Termux side-by-side bundle) ---
+build_busybox_unix() {
+ clone_or_update https://github.com/mirror/busybox.git "$TP/busybox-src"
+ cd "$TP/busybox-src"
+ make defconfig
+ # Keep applets reasonable; enable ash + diff
+ make -j"$JOBS" busybox
+ mkdir -p "$TP/busybox"
+ cp -f busybox "$TP/busybox/busybox"
+ echo "unix busybox -> $TP/busybox/busybox"
+}
+
+TARGETS="${1:-all}"
+case "$TARGETS" in
+ curl) build_curl_mingw ;;
+ pdcurses) build_pdcurses_mingw ;;
+ busybox-w32) build_busybox_w32 ;;
+ busybox-unix) build_busybox_unix ;;
+ all)
+ build_curl_mingw
+ build_pdcurses_mingw
+ build_busybox_w32
+ ;;
+ *)
+ echo "usage: $0 [all|curl|pdcurses|busybox-w32|busybox-unix]"
+ exit 1
+ ;;
+esac
+echo "DONE"
diff --git a/scripts/e2e_mini_calc.sh b/scripts/e2e_mini_calc.sh
new file mode 100755
index 0000000..de6b18d
--- /dev/null
+++ b/scripts/e2e_mini_calc.sh
@@ -0,0 +1,114 @@
+#!/usr/bin/env bash
+# E2E: copy mini_calc → temp workspace → optional cst-agent headless → make test.
+# Usage:
+# ./scripts/e2e_mini_calc.sh # run agent if credentials exist
+# ./scripts/e2e_mini_calc.sh --baseline-only # only prove fixture starts RED then skip agent
+# SKIP_AGENT=1 ./scripts/e2e_mini_calc.sh
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+FIX="$ROOT/fixtures/mini_calc"
+OUT="$FIX/out"
+BASELINE_ONLY=0
+for a in "$@"; do
+ case "$a" in
+ --baseline-only) BASELINE_ONLY=1 ;;
+ esac
+done
+
+mkdir -p "$OUT"
+STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
+WORKDIR="$(mktemp -d /tmp/cst-mini-calc.XXXXXX)"
+RESULT="$OUT/result-$STAMP.txt"
+SESSION_COPY="$OUT/session-$STAMP.jsonl"
+
+cleanup() { rm -rf "$WORKDIR"; }
+trap cleanup EXIT
+
+echo "== mini_calc e2e ==" | tee "$RESULT"
+echo "workdir=$WORKDIR" | tee -a "$RESULT"
+echo "stamp=$STAMP" | tee -a "$RESULT"
+
+# Fresh copy (no .agent from previous runs)
+cp -a "$FIX/." "$WORKDIR/"
+rm -rf "$WORKDIR/out" "$WORKDIR/.agent"
+chmod +x "$WORKDIR/tests/smoke.sh"
+
+cd "$WORKDIR"
+make clean >/dev/null 2>&1 || true
+make
+
+echo "-- baseline make test (expect FAIL before agent) --" | tee -a "$RESULT"
+set +e
+make test >"$OUT/baseline-$STAMP.log" 2>&1
+base_rc=$?
+set -e
+echo "baseline_rc=$base_rc" | tee -a "$RESULT"
+if [[ "$base_rc" -eq 0 ]]; then
+ echo "UNEXPECTED: fixture already passes; bugs may have been fixed in source." | tee -a "$RESULT"
+ echo "outcome=unexpected_green_fixture" | tee -a "$RESULT"
+ exit 2
+fi
+echo "baseline: RED (as expected)" | tee -a "$RESULT"
+
+if [[ "$BASELINE_ONLY" -eq 1 || "${SKIP_AGENT:-0}" == "1" ]]; then
+ echo "outcome=baseline_ok_agent_skipped" | tee -a "$RESULT"
+ echo "SKIP agent step (--baseline-only or SKIP_AGENT=1)"
+ exit 0
+fi
+
+# Need API key for agent step
+has_key=0
+if [[ -n "${CST_API_KEY:-}" || -n "${MINIMAX_API_KEY:-}" ]]; then
+ has_key=1
+elif [[ -f "$HOME/.config/cst-agent/credentials" ]]; then
+ has_key=1
+fi
+if [[ "$has_key" -eq 0 ]]; then
+ echo "outcome=baseline_ok_no_api_key" | tee -a "$RESULT"
+ echo "No API key; agent step skipped. Set CST_API_KEY or save credentials."
+ exit 0
+fi
+
+cd "$ROOT"
+make -s
+
+mkdir -p "$WORKDIR/.agent"
+PROMPT="$(cat "$FIX/TASK.md")"
+export CST_AUTO_APPROVE=1
+echo "-- cst-agent headless --" | tee -a "$RESULT"
+set +e
+"$ROOT/cst-agent" --headless --workspace "$WORKDIR" --session "$WORKDIR/.agent/e2e.jsonl" \
+ --prompt "$PROMPT" >"$OUT/agent-$STAMP.log" 2>&1
+agent_rc=$?
+set -e
+echo "agent_rc=$agent_rc" | tee -a "$RESULT"
+if [[ -f "$WORKDIR/.agent/e2e.jsonl" ]]; then
+ cp -f "$WORKDIR/.agent/e2e.jsonl" "$SESSION_COPY"
+ echo "session_copy=$SESSION_COPY" | tee -a "$RESULT"
+fi
+
+cd "$WORKDIR"
+set +e
+make test >"$OUT/after-$STAMP.log" 2>&1
+test_rc=$?
+set -e
+echo "after_test_rc=$test_rc" | tee -a "$RESULT"
+
+# Classify
+changed=0
+if ! diff -q "$FIX/calc.c" "$WORKDIR/calc.c" >/dev/null 2>&1; then
+ changed=1
+fi
+echo "calc_c_changed=$changed" | tee -a "$RESULT"
+
+if [[ "$test_rc" -eq 0 ]]; then
+ echo "outcome=pass" | tee -a "$RESULT"
+ exit 0
+fi
+if [[ "$changed" -eq 1 ]]; then
+ echo "outcome=partial" | tee -a "$RESULT"
+ exit 1
+fi
+echo "outcome=fail" | tee -a "$RESULT"
+exit 1
diff --git a/scripts/fetch_busybox_w32.sh b/scripts/fetch_busybox_w32.sh
new file mode 100755
index 0000000..9b619cd
--- /dev/null
+++ b/scripts/fetch_busybox_w32.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+# Fetch busybox-w32 into third_party/busybox/busybox.exe (Linux/CI host).
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+DEST="$ROOT/third_party/busybox"
+mkdir -p "$DEST"
+OUT="$DEST/busybox.exe"
+
+# Override with BUSYBOX_W32_URL if the default 404s / TLS fails.
+URLS=(
+ "${BUSYBOX_W32_URL:-}"
+ "https://frippery.org/files/busybox/busybox-w64-FRP-5007-g82accfc19.exe"
+ "https://frippery.org/files/busybox/busybox.exe"
+)
+
+ok=0
+for URL in "${URLS[@]}"; do
+ [[ -z "$URL" ]] && continue
+ echo "Downloading $URL -> $OUT"
+ if curl -fsSL -L --connect-timeout 20 -o "$OUT" "$URL"; then
+ ok=1
+ break
+ fi
+ echo "failed: $URL" >&2
+done
+
+cat > "$DEST/LICENSE.busybox" << 'EOF'
+BusyBox is licensed under GPLv2. Obtain the corresponding source from the
+upstream busybox-w32 / BusyBox project when redistributing the binary.
+See: https://www.busybox.net/ https://frippery.org/busybox/
+EOF
+
+if [[ "$ok" -ne 1 ]]; then
+ cat >&2 <<'EOF'
+Could not download busybox-w32 automatically.
+Manual steps:
+ 1. Visit https://frippery.org/busybox/ or busybox-w32 releases
+ 2. Save the 64-bit binary as agent/third_party/busybox/busybox.exe
+ 3. Copy it next to cst-agent.exe on Windows releases
+EOF
+ exit 1
+fi
+
+chmod +x "$OUT" || true
+file "$OUT" || true
+ls -la "$OUT"
+echo "OK: place this next to cst-agent.exe on Windows releases."
diff --git a/scripts/package_windows.sh b/scripts/package_windows.sh
new file mode 100755
index 0000000..2ad57e1
--- /dev/null
+++ b/scripts/package_windows.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# Assemble dist/windows-x86_64 from MinGW TUI build + busybox.
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+REL="$ROOT/dist/windows-x86_64"
+EXE="$ROOT/build-win-tui/cst-agent.exe"
+[[ -f "$EXE" ]] || EXE="$ROOT/build-win/cst-agent.exe"
+BB="$ROOT/third_party/busybox/busybox.exe"
+DLL="/usr/x86_64-w64-mingw32/bin/libwinpthread-1.dll"
+
+mkdir -p "$REL"
+cp -f "$EXE" "$REL/cst-agent.exe"
+cp -f "$BB" "$REL/busybox.exe"
+cp -f "$ROOT/third_party/busybox/LICENSE.busybox" "$REL/"
+cp -f "$ROOT/docs/README-windows.md" "$REL/"
+[[ -f "$DLL" ]] && cp -f "$DLL" "$REL/"
+ls -la "$REL"
+file "$REL/cst-agent.exe"
diff --git a/scripts/replay_starvation.sh b/scripts/replay_starvation.sh
new file mode 100755
index 0000000..37f7684
--- /dev/null
+++ b/scripts/replay_starvation.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+# Replay starvation experiment (CTL vs JSON-buffer).
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+make check
+echo
+echo "See docs/theory_tool_cst.md for the claim under test."
diff --git a/scripts/run_windows.bat b/scripts/run_windows.bat
new file mode 100755
index 0000000..e0c8eff
--- /dev/null
+++ b/scripts/run_windows.bat
@@ -0,0 +1,27 @@
+@echo off
+REM Run ON Windows after copying dist/windows-x86_64 (or a full git checkout + MSYS2 build).
+REM This script only documents env + launch; prefer bootstrap_windows.sh on Linux for cross-build.
+
+setlocal
+if "%OPENAI_API_KEY%"=="" if "%CST_API_KEY%"=="" (
+ echo Set OPENAI_API_KEY or CST_API_KEY first. See docs\env_openai.md
+ echo Example:
+ echo set OPENAI_API_KEY=sk-...
+ echo set OPENAI_BASE_URL=https://api.openai.com/v1
+ echo set OPENAI_MODEL=gpt-4.1-mini
+ exit /b 1
+)
+
+if not exist cst-agent.exe (
+ echo cst-agent.exe not found in %CD%
+ echo Copy dist\windows-x86_64\* here, or build with MSYS2/MinGW.
+ exit /b 1
+)
+
+if not exist busybox.exe (
+ echo WARNING: busybox.exe missing — run_shell may fail. Place busybox.exe beside cst-agent.exe
+)
+
+echo Launching CST-Agent...
+cst-agent.exe --workspace .
+endlocal
diff --git a/src/agent_loop.c b/src/agent_loop.c
new file mode 100644
index 0000000..fb24ff9
--- /dev/null
+++ b/src/agent_loop.c
@@ -0,0 +1,1182 @@
+#include "cst_agent.h"
+#include "mm_tool.h"
+
+#include
+#include
+#include
+
+const char *cst_agent_system_prompt(void) {
+ return "You are CST-Agent, a coding assistant in a local workspace.\n"
+ "Reply in the user's language. Be concise.\n"
+ "\n"
+ "Allowed tools ONLY: read_file, run_shell, write_file, apply_patch.\n"
+ "Never invent other tool names (e.g. never call a tool named cmd).\n"
+ "\n"
+ "Prefer native function tools when the API offers them "
+ "(read_file path; run_shell cmd; write_file path+content; "
+ "apply_patch patch).\n"
+ "XML fallback format (exact):\n"
+ "\n"
+ "invoke read_file\n"
+ "agent/README.md \n"
+ " \n"
+ "\n"
+ "This workspace often has top-level dirs agent/ and SD/.\n"
+ "If README.md is missing at root, try agent/README.md or:\n"
+ "invoke run_shell with ls -la \n"
+ "\n"
+ "Workflow for summarize/explore:\n"
+ "1) Prefer few tool calls; stop when you can answer.\n"
+ "2) If a tool fails, try one different path — then answer.\n"
+ "3) After enough context, write the final answer in plain text only.\n"
+ "4) Never repeat a tool+args you already ran.\n"
+ "Workflow for fix/implement tasks:\n"
+ "1) Read the relevant files, then write/patch, then run tests via shell.\n"
+ "2) You may use several tool rounds; finish with a short Chinese summary.\n";
+}
+
+static int tool_allowed(const char *name) {
+ if (!name || !name[0])
+ return 0;
+ return strcmp(name, "read_file") == 0 || strcmp(name, "run_shell") == 0 ||
+ strcmp(name, "write_file") == 0 || strcmp(name, "apply_patch") == 0;
+}
+
+static void tool_fingerprint(const ctl_tool_call_t *c, char *out, size_t n) {
+ out[0] = 0;
+ if (!c)
+ return;
+ size_t used = 0;
+ int k = snprintf(out + used, n - used, "%s", c->name);
+ if (k > 0)
+ used += (size_t)k;
+ for (int i = 0; i < c->nargs && used + 2 < n; i++) {
+ k = snprintf(out + used, n - used, "|%s=", c->args[i].key);
+ if (k > 0)
+ used += (size_t)k;
+ if (c->args[i].value) {
+ k = snprintf(out + used, n - used, "%s", c->args[i].value);
+ if (k > 0)
+ used += (size_t)k;
+ }
+ }
+}
+
+#define FP_MAX 32
+#define FP_LEN 256
+/* Session/TUI keep full write diffs; LLM request truncates separately. */
+#define TOOL_OUT_CAP (48 * 1024)
+
+/* OpenCode max-steps style: after tools, ban further tools and demand text. */
+static const char *FINAL_NUDGE =
+ "[系统] CRITICAL — 工具结果已在上文。\n"
+ "1) 禁止再次调用任何工具(不要输出 )。\n"
+ "2) 必须用中文纯文本回答用户问题,基于上述工具输出做总结。\n"
+ "3) 不要只输出思考或标签。\n";
+
+static const char *EMPTY_NUDGE =
+ "[系统] 上一轮没有产生可见回答。请立刻用中文直接回答;"
+ "若缺文件信息再调用 read_file 读 agent/README.md。";
+
+static const char *CONTINUE_FIX_NUDGE =
+ "[系统] 还没有完成修改。请继续调用工具:用 write_file 或 apply_patch 改代码,"
+ "再用 run_shell 执行 make test;通过后再用中文简短说明。";
+
+static const char *RETRY_ANSWER_NUDGE =
+ "[系统] 你的上一轮回复为空或只有无效标签。"
+ "请立刻用中文写出完整总结,禁止工具调用。";
+
+static int text_is_meaningful(const char *s, size_t n) {
+ size_t letters = 0;
+ for (size_t i = 0; i < n; i++) {
+ unsigned char c = (unsigned char)s[i];
+ if (c > 0x20)
+ letters++;
+ }
+ /* ~几字中文即可;过严会导致工具后短答被当成空。 */
+ return letters >= 4;
+}
+
+/* Strip MiniMax tool markup leftovers from assistant text. */
+static void strip_tool_markup(char *s, size_t *len_io) {
+ if (!s || !len_io)
+ return;
+ static const char *tags[] = {
+ " ",
+ "",
+ "",
+ "",
+ NULL,
+ };
+ for (int t = 0; tags[t]; t++) {
+ const char *tag = tags[t];
+ size_t olen = strlen(tag);
+ char *p = s;
+ while ((p = strstr(p, tag)) != NULL) {
+ size_t rest = *len_io - (size_t)(p - s) - olen;
+ memmove(p, p + olen, rest + 1);
+ *len_io -= olen;
+ }
+ }
+}
+
+
+typedef struct {
+ char *text_acc;
+ size_t text_len;
+ size_t text_cap;
+ ctl_tool_call_t **calls;
+ int ncalls;
+ int ncalls_cap;
+ int n_native; /* calls added via on_native_tool */
+ int parse_error;
+ char err[256];
+ const cst_agent_hooks_t *hooks;
+} sink_t;
+
+static ctl_tool_call_t *deep_copy_call(const ctl_tool_call_t *src) {
+ ctl_tool_call_t *d = calloc(1, sizeof(*d));
+ if (!d)
+ return NULL;
+ memcpy(d->id, src->id, sizeof(d->id));
+ memcpy(d->name, src->name, sizeof(d->name));
+ d->nargs = src->nargs;
+ for (int i = 0; i < src->nargs; i++) {
+ memcpy(d->args[i].key, src->args[i].key, sizeof(d->args[i].key));
+ d->args[i].value_len = src->args[i].value_len;
+ d->args[i].value = malloc(src->args[i].value_len + 1);
+ if (!d->args[i].value) {
+ for (int j = 0; j < i; j++)
+ free(d->args[j].value);
+ free(d);
+ return NULL;
+ }
+ if (src->args[i].value_len)
+ memcpy(d->args[i].value, src->args[i].value, src->args[i].value_len);
+ d->args[i].value[src->args[i].value_len] = 0;
+ }
+ return d;
+}
+
+static void free_call(ctl_tool_call_t *c) {
+ if (!c)
+ return;
+ for (int i = 0; i < c->nargs; i++)
+ free(c->args[i].value);
+ free(c);
+}
+
+static void sink_free(sink_t *s) {
+ free(s->text_acc);
+ for (int i = 0; i < s->ncalls; i++)
+ free_call(s->calls[i]);
+ free(s->calls);
+ memset(s, 0, sizeof(*s));
+}
+
+static void hook_status(const sink_t *s, const char *msg) {
+ if (s->hooks && s->hooks->on_status)
+ s->hooks->on_status(msg, s->hooks->userdata);
+}
+
+static void on_ctl(const ctl_event_t *ev, void *ud) {
+ sink_t *s = ud;
+ if (ev->type == CTL_EV_TEXT && ev->text && ev->text_len) {
+ if (s->text_len + ev->text_len + 1 > s->text_cap) {
+ size_t nc = s->text_cap ? s->text_cap * 2 : 4096;
+ while (nc < s->text_len + ev->text_len + 1)
+ nc *= 2;
+ char *nb = realloc(s->text_acc, nc);
+ if (!nb)
+ return;
+ s->text_acc = nb;
+ s->text_cap = nc;
+ }
+ memcpy(s->text_acc + s->text_len, ev->text, ev->text_len);
+ s->text_len += ev->text_len;
+ s->text_acc[s->text_len] = 0;
+ if (s->hooks && s->hooks->on_assistant_delta)
+ s->hooks->on_assistant_delta(ev->text, ev->text_len, s->hooks->userdata);
+ } else if (ev->type == CTL_EV_CALL_OPEN && ev->call) {
+ if (!tool_allowed(ev->call->name))
+ return;
+ if (s->hooks && s->hooks->on_tool_event)
+ s->hooks->on_tool_event("open", ev->call, NULL, s->hooks->userdata);
+ } else if (ev->type == CTL_EV_ARG && ev->call) {
+ if (!tool_allowed(ev->call->name))
+ return;
+ if (s->hooks && s->hooks->on_tool_event)
+ s->hooks->on_tool_event("arg", ev->call, NULL, s->hooks->userdata);
+ } else if (ev->type == CTL_EV_CALL_READY && ev->call) {
+ /* Prefer native tool_calls; skip XML duplicates when native already fired. */
+ if (s->n_native > 0)
+ return;
+ if (!tool_allowed(ev->call->name)) {
+ /* Ignore unknown tools (e.g. spurious "cmd") instead of looping. */
+ if (s->hooks && s->hooks->on_status) {
+ char msg[128];
+ snprintf(msg, sizeof(msg), "ignored unknown tool: %s", ev->call->name);
+ s->hooks->on_status(msg, s->hooks->userdata);
+ }
+ return;
+ }
+ if (s->hooks && s->hooks->on_tool_event)
+ s->hooks->on_tool_event("ready", ev->call, NULL, s->hooks->userdata);
+ ctl_tool_call_t *cp = deep_copy_call(ev->call);
+ if (!cp)
+ return;
+ if (s->ncalls >= s->ncalls_cap) {
+ int nc = s->ncalls_cap ? s->ncalls_cap * 2 : 4;
+ ctl_tool_call_t **narr =
+ realloc(s->calls, (size_t)nc * sizeof(ctl_tool_call_t *));
+ if (!narr) {
+ free_call(cp);
+ return;
+ }
+ s->calls = narr;
+ s->ncalls_cap = nc;
+ }
+ s->calls[s->ncalls++] = cp;
+ } else if (ev->type == CTL_EV_ERROR) {
+ s->parse_error = 1;
+ snprintf(s->err, sizeof(s->err), "%s", ev->error ? ev->error : "ctl error");
+ }
+}
+
+/* Strip MiniMax ... ; route visible text through MiniMax tool
+ * scanner then CTL. */
+typedef struct {
+ ctl_parser_t *parser;
+ mm_scan_t *mm;
+ sink_t *sink;
+ const cst_agent_hooks_t *hooks;
+ int in_think;
+ char tagbuf[16];
+ size_t taglen;
+} delta_ctx_t;
+
+static void on_mm(const ctl_event_t *ev, void *ud) {
+ delta_ctx_t *d = ud;
+ if (ev->type == CTL_EV_TEXT && ev->text && ev->text_len) {
+ ctl_parser_feed(d->parser, ev->text, ev->text_len);
+ } else if (ev->type == CTL_EV_CALL_OPEN || ev->type == CTL_EV_CALL_READY ||
+ ev->type == CTL_EV_ARG || ev->type == CTL_EV_ERROR) {
+ on_ctl(ev, d->sink);
+ }
+}
+
+static void feed_visible(delta_ctx_t *d, const char *data, size_t len) {
+ if (!len)
+ return;
+ if (d->mm)
+ mm_scan_feed(d->mm, data, len);
+ else
+ ctl_parser_feed(d->parser, data, len);
+}
+
+static int sink_push_call(sink_t *s, ctl_tool_call_t *cp) {
+ if (!s || !cp)
+ return -1;
+ if (s->ncalls >= s->ncalls_cap) {
+ int nc = s->ncalls_cap ? s->ncalls_cap * 2 : 4;
+ ctl_tool_call_t **narr =
+ realloc(s->calls, (size_t)nc * sizeof(ctl_tool_call_t *));
+ if (!narr)
+ return -1;
+ s->calls = narr;
+ s->ncalls_cap = nc;
+ }
+ s->calls[s->ncalls++] = cp;
+ return 0;
+}
+
+static void on_native_tool(const char *id, const char *name,
+ const char *args_json, void *ud) {
+ delta_ctx_t *d = ud;
+ sink_t *s = d ? d->sink : NULL;
+ if (!s || !name)
+ return;
+ if (!tool_allowed(name)) {
+ if (s->hooks && s->hooks->on_status) {
+ char msg[128];
+ snprintf(msg, sizeof(msg), "ignored unknown tool: %s", name);
+ s->hooks->on_status(msg, s->hooks->userdata);
+ }
+ return;
+ }
+ ctl_tool_call_t tmp = {0};
+ if (cst_tool_call_from_native(&tmp, id, name, args_json) != 0)
+ return;
+ if (!tmp.id[0])
+ snprintf(tmp.id, sizeof(tmp.id), "native_%d", s->n_native + 1);
+ if (s->hooks && s->hooks->on_tool_event)
+ s->hooks->on_tool_event("ready", &tmp, NULL, s->hooks->userdata);
+ ctl_tool_call_t *cp = deep_copy_call(&tmp);
+ for (int i = 0; i < tmp.nargs; i++)
+ free(tmp.args[i].value);
+ if (!cp)
+ return;
+ if (sink_push_call(s, cp) != 0) {
+ free_call(cp);
+ return;
+ }
+ s->n_native++;
+}
+
+static void on_delta(const char *data, size_t len, void *ud) {
+ delta_ctx_t *d = ud;
+ for (size_t i = 0; i < len; i++) {
+ char c = data[i];
+ if (!d->in_think) {
+ if (c == '<') {
+ d->tagbuf[0] = '<';
+ d->taglen = 1;
+ continue;
+ }
+ if (d->taglen > 0) {
+ if (d->taglen + 1 < sizeof(d->tagbuf))
+ d->tagbuf[d->taglen++] = c;
+ else {
+ feed_visible(d, d->tagbuf, d->taglen);
+ d->taglen = 0;
+ feed_visible(d, &c, 1);
+ continue;
+ }
+ d->tagbuf[d->taglen] = 0;
+ if (d->taglen == 7 && strncmp(d->tagbuf, "", 7) == 0) {
+ d->in_think = 1;
+ d->taglen = 0;
+ continue;
+ }
+ /* not a think open — if cannot be prefix of , flush */
+ if (strncmp("", d->tagbuf, d->taglen) != 0) {
+ feed_visible(d, d->tagbuf, d->taglen);
+ d->taglen = 0;
+ }
+ continue;
+ }
+ feed_visible(d, &c, 1);
+ } else {
+ /* in think: look for ; do NOT feed mm/ctl (avoids UTF-8
+ * keep-buffer contamination into the visible answer). */
+ if (c == '<') {
+ d->tagbuf[0] = '<';
+ d->taglen = 1;
+ continue;
+ }
+ if (d->taglen > 0) {
+ if (d->taglen + 1 < sizeof(d->tagbuf))
+ d->tagbuf[d->taglen++] = c;
+ else {
+ if (d->hooks && d->hooks->on_think_delta)
+ d->hooks->on_think_delta(d->tagbuf, d->taglen, d->hooks->userdata);
+ d->taglen = 0;
+ if (d->hooks && d->hooks->on_think_delta)
+ d->hooks->on_think_delta(&c, 1, d->hooks->userdata);
+ continue;
+ }
+ d->tagbuf[d->taglen] = 0;
+ if (d->taglen == 8 && strncmp(d->tagbuf, " ", 8) == 0) {
+ d->in_think = 0;
+ d->taglen = 0;
+ continue;
+ }
+ if (strncmp(" ", d->tagbuf, d->taglen) != 0) {
+ if (d->hooks && d->hooks->on_think_delta)
+ d->hooks->on_think_delta(d->tagbuf, d->taglen, d->hooks->userdata);
+ d->taglen = 0;
+ }
+ continue;
+ }
+ if (d->hooks && d->hooks->on_think_delta)
+ d->hooks->on_think_delta(&c, 1, d->hooks->userdata);
+ }
+ }
+}
+
+struct cst_agent {
+ cst_agent_config_t cfg;
+ cst_session_t *session;
+ volatile int cancel;
+};
+
+cst_agent_t *cst_agent_create(const cst_agent_config_t *cfg) {
+ if (!cfg)
+ return NULL;
+ cst_agent_t *a = calloc(1, sizeof(*a));
+ if (!a)
+ return NULL;
+ a->cfg = *cfg;
+ a->session = cst_session_open(cfg->session_path[0] ? cfg->session_path
+ : "session.jsonl");
+ if (!a->session) {
+ free(a);
+ return NULL;
+ }
+ /* Only seed system once per process session object (file may already have
+ * history from prior runs — still append system for this in-memory view). */
+ if (cst_session_count(a->session) == 0) {
+ cst_message_t sys = {0};
+ sys.role = CST_ROLE_SYSTEM;
+ sys.content = (char *)cst_agent_system_prompt();
+ cst_session_append(a->session, &sys);
+ }
+ return a;
+}
+
+void cst_agent_destroy(cst_agent_t *a) {
+ if (!a)
+ return;
+ cst_session_close(a->session);
+ free(a);
+}
+
+cst_session_t *cst_agent_session(cst_agent_t *a) {
+ return a ? a->session : NULL;
+}
+
+const cst_agent_config_t *cst_agent_config(const cst_agent_t *a) {
+ return a ? &a->cfg : NULL;
+}
+
+cst_agent_config_t *cst_agent_config_mut(cst_agent_t *a) {
+ return a ? &a->cfg : NULL;
+}
+
+void cst_agent_request_cancel(cst_agent_t *a) {
+ if (a)
+ a->cancel = 1;
+}
+
+int cst_agent_run_turn(cst_agent_t *a, const char *user_text, char *err,
+ size_t err_sz) {
+ return cst_agent_run_turn_ex(a, user_text, NULL, err, err_sz);
+}
+
+int cst_agent_run_turn_ex(cst_agent_t *a, const char *user_text,
+ const cst_agent_hooks_t *hooks, char *err,
+ size_t err_sz) {
+ if (err && err_sz)
+ err[0] = 0;
+ if (!a || !user_text)
+ return -1;
+
+ a->cancel = 0;
+
+ cst_message_t um = {0};
+ um.role = CST_ROLE_USER;
+ um.content = (char *)user_text;
+ if (cst_session_append(a->session, &um) != 0) {
+ if (err)
+ snprintf(err, err_sz, "session append failed");
+ return -1;
+ }
+
+ const int max_iters = 6;
+ char seen_fp[FP_MAX][FP_LEN];
+ int nseen = 0;
+ int tool_rounds = 0;
+ int force_answer = 0;
+ int empty_retries = 0;
+ int answer_retries = 0;
+ int tools_ran = 0; /* OpenCode: after tools, must continue until text */
+
+ for (int iter = 0; iter < max_iters; iter++) {
+ if (force_answer) {
+ cst_message_t nudge = {0};
+ nudge.role = CST_ROLE_USER;
+ nudge.content =
+ (char *)(answer_retries > 0 ? RETRY_ANSWER_NUDGE : FINAL_NUDGE);
+ cst_session_append(a->session, &nudge);
+ }
+
+ sink_t sink = {0};
+ sink.hooks = hooks;
+ ctl_parser_t *parser = ctl_parser_create(on_ctl, &sink);
+ if (!parser) {
+ if (err)
+ snprintf(err, err_sz, "parser create failed");
+ return -1;
+ }
+ delta_ctx_t dctx = {0};
+ dctx.parser = parser;
+ dctx.sink = &sink;
+ dctx.hooks = hooks;
+ dctx.mm = mm_scan_create(on_mm, &dctx);
+ if (!dctx.mm) {
+ ctl_parser_destroy(parser);
+ if (err)
+ snprintf(err, err_sz, "mm scanner create failed");
+ return -1;
+ }
+
+ int n = cst_session_count(a->session);
+ /* system + last CST_AGENT_CTX_WINDOW non-system messages */
+ int nonsys = 0;
+ int have_sys = 0;
+ for (int i = 0; i < n; i++) {
+ const cst_message_t *m = cst_session_get(a->session, i);
+ if (m->role == CST_ROLE_SYSTEM)
+ have_sys = 1;
+ else
+ nonsys++;
+ }
+ int skip_nonsys = 0;
+ if (nonsys > CST_AGENT_CTX_WINDOW)
+ skip_nonsys = nonsys - CST_AGENT_CTX_WINDOW;
+ int extra = have_sys ? 0 : 1;
+ cst_message_t *msgs = calloc((size_t)(n + extra), sizeof(cst_message_t));
+ if (!msgs) {
+ mm_scan_destroy(dctx.mm);
+ ctl_parser_destroy(parser);
+ return -1;
+ }
+ int mi = 0;
+ if (!have_sys) {
+ msgs[mi].role = CST_ROLE_SYSTEM;
+ msgs[mi].content = (char *)cst_agent_system_prompt();
+ mi++;
+ }
+ int skipped = 0;
+ for (int i = 0; i < n; i++) {
+ const cst_message_t *m = cst_session_get(a->session, i);
+ if (m->role == CST_ROLE_SYSTEM) {
+ msgs[mi++] = *m;
+ continue;
+ }
+ if (skipped < skip_nonsys) {
+ skipped++;
+ continue;
+ }
+ msgs[mi++] = *m;
+ }
+ /* Drop leading orphan tool results (no preceding assistant tool_calls). */
+ {
+ int sys_end = 0;
+ while (sys_end < mi && msgs[sys_end].role == CST_ROLE_SYSTEM)
+ sys_end++;
+ while (sys_end < mi && msgs[sys_end].role == CST_ROLE_TOOL) {
+ for (int j = sys_end; j + 1 < mi; j++)
+ msgs[j] = msgs[j + 1];
+ mi--;
+ }
+ }
+
+ hook_status(&sink, force_answer ? "final answer pass..." : "calling model...");
+
+ cst_llm_request_t req = {0};
+ req.api_base = a->cfg.api_base;
+ req.api_key = a->cfg.api_key;
+ req.model = a->cfg.model;
+ req.msgs = msgs;
+ req.nmsgs = mi;
+ req.on_delta = on_delta;
+ req.on_native_tool = on_native_tool;
+ req.userdata = &dctx;
+ req.enable_tools = 1; /* keep tools[] schema even on force-answer */
+ req.tool_choice_none = force_answer;
+ req.cancel_flag = &a->cancel;
+
+ char lerr[512];
+ int lrc = cst_llm_chat_stream(&req, lerr, sizeof(lerr));
+ free(msgs);
+ if (lrc != 0) {
+ mm_scan_destroy(dctx.mm);
+ ctl_parser_destroy(parser);
+ sink_free(&sink);
+ if (err)
+ snprintf(err, err_sz, "llm: %s", lerr);
+ return -1;
+ }
+
+ if (!dctx.in_think && dctx.taglen)
+ feed_visible(&dctx, dctx.tagbuf, dctx.taglen);
+
+ mm_scan_finish(dctx.mm);
+ mm_scan_destroy(dctx.mm);
+ dctx.mm = NULL;
+
+ ctl_status_t fr = ctl_parser_finish(parser);
+ ctl_parser_destroy(parser);
+
+ /* Meaningful assistant text after orphan-tag cleanup.
+ * If there are tool calls, defer append so we can attach tool_calls_json. */
+ int has_text = 0;
+ char *text_body = NULL;
+ if (sink.text_acc && sink.text_len) {
+ strip_tool_markup(sink.text_acc, &sink.text_len);
+ if (text_is_meaningful(sink.text_acc, sink.text_len)) {
+ size_t i = 0;
+ while (i < sink.text_len &&
+ (sink.text_acc[i] == ' ' || sink.text_acc[i] == '\n' ||
+ sink.text_acc[i] == '\r' || sink.text_acc[i] == '\t'))
+ i++;
+ if (i < sink.text_len) {
+ text_body = sink.text_acc + i;
+ has_text = 1;
+ }
+ }
+ }
+ if (has_text && sink.ncalls == 0) {
+ cst_message_t am = {0};
+ am.role = CST_ROLE_ASSISTANT;
+ am.content = text_body;
+ cst_session_append(a->session, &am);
+ }
+
+ if ((sink.parse_error || fr != CTL_OK) && sink.ncalls == 0) {
+ if (has_text) {
+ hook_status(&sink, "done (text only)");
+ sink_free(&sink);
+ return 0;
+ }
+ /* Incomplete CTL but we may still need an answer after tools. */
+ if (!tools_ran) {
+ if (err)
+ snprintf(err, err_sz, "ctl: %s",
+ sink.err[0] ? sink.err : "incomplete");
+ sink_free(&sink);
+ return -1;
+ }
+ }
+
+ /*
+ * OpenCode: finish=stop with no pending tools ends the loop.
+ * Gap we fill: empty text AFTER tools must NOT end — force text.
+ */
+ if (force_answer) {
+ /* Drop any further tool calls on the answer pass. */
+ sink.ncalls = 0;
+ if (has_text) {
+ hook_status(&sink, "done");
+ sink_free(&sink);
+ return 0;
+ }
+ sink_free(&sink);
+ if (answer_retries < 2) {
+ answer_retries++;
+ if (hooks && hooks->on_status)
+ hooks->on_status("empty after tools — forcing answer...",
+ hooks->userdata);
+ continue;
+ }
+ /* Last resort: surface last tool output so the turn isn't blank. */
+ if (tools_ran) {
+ int n = cst_session_count(a->session);
+ for (int i = n - 1; i >= 0; i--) {
+ const cst_message_t *m = cst_session_get(a->session, i);
+ if (!m || m->role != CST_ROLE_TOOL || !m->content)
+ continue;
+ char *syn = malloc(strlen(m->content) + 128);
+ if (syn) {
+ snprintf(syn, strlen(m->content) + 128,
+ "(模型未返回正文,以下为最近工具输出摘要)\n%.2000s",
+ m->content);
+ cst_message_t am = {0};
+ am.role = CST_ROLE_ASSISTANT;
+ am.content = syn;
+ cst_session_append(a->session, &am);
+ free(syn);
+ }
+ if (hooks && hooks->on_status)
+ hooks->on_status("empty answer — showing tool output fallback",
+ hooks->userdata);
+ return 0;
+ }
+ }
+ if (err)
+ snprintf(err, err_sz, "empty model response");
+ return -1;
+ }
+
+ if (sink.ncalls == 0) {
+ if (has_text) {
+ hook_status(&sink, "done");
+ sink_free(&sink);
+ return 0;
+ }
+ sink_free(&sink);
+ /* Empty after tools: if rounds remain, nudge to keep fixing — do not
+ * ban tools yet (coding tasks need write/test after read). */
+ if (tools_ran && tool_rounds < 4) {
+ cst_message_t nudge = {0};
+ nudge.role = CST_ROLE_USER;
+ nudge.content = (char *)CONTINUE_FIX_NUDGE;
+ cst_session_append(a->session, &nudge);
+ if (hooks && hooks->on_status)
+ hooks->on_status("continuing after tools…", hooks->userdata);
+ continue;
+ }
+ if (tools_ran) {
+ force_answer = 1;
+ if (hooks && hooks->on_status)
+ hooks->on_status("tools done — requesting final answer...",
+ hooks->userdata);
+ continue;
+ }
+ if (empty_retries < 2) {
+ empty_retries++;
+ cst_message_t nudge = {0};
+ nudge.role = CST_ROLE_USER;
+ nudge.content = (char *)EMPTY_NUDGE;
+ cst_session_append(a->session, &nudge);
+ if (hooks && hooks->on_status)
+ hooks->on_status("empty response — retrying...", hooks->userdata);
+ continue;
+ }
+ if (err)
+ snprintf(err, err_sz, "empty model response");
+ return -1;
+ }
+
+ /* Have tool calls — execute (OpenCode continues while tools pending). */
+ int executed = 0;
+
+ /* Ensure every call has a non-empty id (API requires tool_call_id match). */
+ for (int i = 0; i < sink.ncalls; i++) {
+ ctl_tool_call_t *c = sink.calls[i];
+ if (!c->id[0])
+ snprintf(c->id, sizeof(c->id), "call_%d", i + 1);
+ }
+
+ /* MiniMax/OpenAI: assistant tool_calls must precede role=tool results. */
+ {
+ size_t cap = 1024;
+ char *tcj = malloc(cap);
+ if (tcj) {
+ size_t len = 0;
+ tcj[len++] = '[';
+ tcj[len] = 0;
+ int first = 1;
+ for (int i = 0; i < sink.ncalls; i++) {
+ ctl_tool_call_t *c = sink.calls[i];
+ if (!tool_allowed(c->name))
+ continue;
+ /* Rebuild arguments object from args */
+ char argsbuf[4096];
+ size_t al = 0;
+ argsbuf[al++] = '{';
+ for (int ai = 0; ai < c->nargs; ai++) {
+ if (ai)
+ argsbuf[al++] = ',';
+ al += (size_t)snprintf(argsbuf + al, sizeof(argsbuf) - al,
+ "\"%s\":\"", c->args[ai].key);
+ const char *v = c->args[ai].value ? c->args[ai].value : "";
+ for (; *v && al + 8 < sizeof(argsbuf); v++) {
+ if (*v == '"' || *v == '\\') {
+ argsbuf[al++] = '\\';
+ argsbuf[al++] = *v;
+ } else if (*v == '\n') {
+ argsbuf[al++] = '\\';
+ argsbuf[al++] = 'n';
+ } else {
+ argsbuf[al++] = *v;
+ }
+ }
+ argsbuf[al++] = '"';
+ }
+ argsbuf[al++] = '}';
+ argsbuf[al] = 0;
+
+ char piece[4600];
+ int pn = snprintf(
+ piece, sizeof(piece),
+ "%s{\"id\":\"%s\",\"type\":\"function\",\"function\":{\"name\":\"%s\","
+ "\"arguments\":",
+ first ? "" : ",", c->id, c->name);
+ first = 0;
+ size_t need = len + (size_t)pn + al + 8;
+ if (need > cap) {
+ while (cap < need)
+ cap *= 2;
+ char *nb = realloc(tcj, cap);
+ if (!nb)
+ break;
+ tcj = nb;
+ }
+ memcpy(tcj + len, piece, (size_t)pn);
+ len += (size_t)pn;
+ /* arguments value must be a JSON string containing the object */
+ tcj[len++] = '"';
+ for (size_t k = 0; k < al; k++) {
+ char ch = argsbuf[k];
+ if (ch == '"' || ch == '\\') {
+ tcj[len++] = '\\';
+ tcj[len++] = ch;
+ } else if (ch == '\n') {
+ tcj[len++] = '\\';
+ tcj[len++] = 'n';
+ } else {
+ tcj[len++] = ch;
+ }
+ }
+ tcj[len++] = '"';
+ tcj[len++] = '}';
+ tcj[len++] = '}';
+ tcj[len] = 0;
+ }
+ if (!first) {
+ if (len + 2 > cap) {
+ char *nb = realloc(tcj, len + 2);
+ if (nb) {
+ tcj = nb;
+ cap = len + 2;
+ }
+ }
+ tcj[len++] = ']';
+ tcj[len] = 0;
+ cst_message_t am = {0};
+ am.role = CST_ROLE_ASSISTANT;
+ am.content = text_body ? text_body : (char *)"";
+ am.tool_calls_json = tcj;
+ cst_session_append(a->session, &am);
+ }
+ free(tcj);
+ }
+ }
+
+ for (int i = 0; i < sink.ncalls; i++) {
+ ctl_tool_call_t *c = sink.calls[i];
+ if (!tool_allowed(c->name))
+ continue;
+
+ char fp[FP_LEN];
+ tool_fingerprint(c, fp, sizeof(fp));
+ int dup = 0;
+ for (int j = 0; j < nseen; j++) {
+ if (strcmp(seen_fp[j], fp) == 0) {
+ dup = 1;
+ break;
+ }
+ }
+ if (dup) {
+ if (hooks && hooks->on_status)
+ hooks->on_status("skip duplicate tool", hooks->userdata);
+ /* Still pair a tool result so the API accepts the prior tool_calls. */
+ cst_message_t tm = {0};
+ tm.role = CST_ROLE_TOOL;
+ snprintf(tm.tool_call_id, sizeof(tm.tool_call_id), "%s", c->id);
+ snprintf(tm.tool_name, sizeof(tm.tool_name), "%s", c->name);
+ tm.content = (char *)"skipped duplicate tool call";
+ cst_session_append(a->session, &tm);
+ executed++;
+ continue;
+ }
+ if (nseen < FP_MAX)
+ snprintf(seen_fp[nseen++], FP_LEN, "%s", fp);
+
+ /* Permission gate for shell / write tools */
+ int is_shell = strcmp(c->name, "run_shell") == 0;
+ int is_write = strcmp(c->name, "write_file") == 0 ||
+ strcmp(c->name, "apply_patch") == 0;
+ if (is_shell || is_write) {
+ char detail[256];
+ detail[0] = 0;
+ for (int ai = 0; ai < c->nargs && !detail[0]; ai++) {
+ if (c->args[ai].value) {
+ snprintf(detail, sizeof(detail), "%s=%s", c->args[ai].key,
+ c->args[ai].value);
+ }
+ }
+ int allow = 1;
+ int need_confirm =
+ is_shell ? a->cfg.confirm_shell : a->cfg.confirm_write;
+ if (hooks && hooks->on_permission) {
+ allow = hooks->on_permission(c->name, detail[0] ? detail : c->name,
+ hooks->userdata);
+ } else if (need_confirm && !a->cfg.auto_approve) {
+ /* No interactive hook: deny when confirmation required. */
+ allow = 0;
+ }
+ if (!allow) {
+ cst_message_t tm = {0};
+ tm.role = CST_ROLE_TOOL;
+ snprintf(tm.tool_call_id, sizeof(tm.tool_call_id), "%s", c->id);
+ snprintf(tm.tool_name, sizeof(tm.tool_name), "%s", c->name);
+ tm.content = (char *)"denied by user";
+ cst_session_append(a->session, &tm);
+ if (hooks && hooks->on_tool_event)
+ hooks->on_tool_event("denied", c, detail, hooks->userdata);
+ executed++;
+ continue;
+ }
+ }
+
+ if (hooks && hooks->on_tool_event)
+ hooks->on_tool_event("run", c, NULL, hooks->userdata);
+ cst_tool_result_t tr = {0};
+ cst_tool_dispatch(a->cfg.workspace_root, c, &tr);
+ char detail[128];
+ snprintf(detail, sizeof(detail), "exit=%d bytes=%zu", tr.exit_code,
+ tr.out_len);
+
+ const char *body = tr.out ? tr.out : "";
+ size_t blen = tr.out_len;
+ int truncated = 0;
+ if (blen > TOOL_OUT_CAP) {
+ blen = TOOL_OUT_CAP;
+ truncated = 1;
+ }
+ char hdr[256];
+ snprintf(hdr, sizeof(hdr), "[tool %s id=%s exit=%d]", c->name, c->id,
+ tr.exit_code);
+ size_t clen = strlen(hdr) + 2 + blen + 32;
+ char *combined = malloc(clen);
+ if (combined) {
+ if (truncated)
+ snprintf(combined, clen, "%s\n%.*s\n...(truncated)", hdr, (int)blen,
+ body);
+ else
+ snprintf(combined, clen, "%s\n%.*s", hdr, (int)blen, body);
+ cst_message_t tm = {0};
+ tm.role = CST_ROLE_TOOL;
+ snprintf(tm.tool_call_id, sizeof(tm.tool_call_id), "%s", c->id);
+ snprintf(tm.tool_name, sizeof(tm.tool_name), "%s", c->name);
+ tm.content = combined;
+ /* Append before done hook so TUI can attach output immediately. */
+ cst_session_append(a->session, &tm);
+ free(combined);
+ }
+ if (hooks && hooks->on_tool_event)
+ hooks->on_tool_event("done", c, detail, hooks->userdata);
+ cst_tool_result_free(&tr);
+ executed++;
+ }
+
+ sink_free(&sink);
+
+ tool_rounds++;
+ if (executed > 0)
+ tools_ran = 1;
+
+ /* Max tool rounds before forcing a text-only answer (coding needs >1). */
+ if (tool_rounds >= 4)
+ force_answer = 1;
+ if (hooks && hooks->on_status) {
+ if (force_answer)
+ hooks->on_status("tools done — requesting final answer...",
+ hooks->userdata);
+ else
+ hooks->on_status("tool round done...", hooks->userdata);
+ }
+ }
+ if (err)
+ snprintf(err, err_sz, "max tool iterations");
+ return -1;
+}
+
+static int agent_reopen_session(cst_agent_t *a, const char *path) {
+ cst_session_t *ns = cst_session_open(path);
+ if (!ns)
+ return -1;
+ cst_session_close(a->session);
+ a->session = ns;
+ snprintf(a->cfg.session_path, sizeof(a->cfg.session_path), "%s", path);
+ if (cst_session_count(a->session) == 0) {
+ cst_message_t sys = {0};
+ sys.role = CST_ROLE_SYSTEM;
+ sys.content = (char *)cst_agent_system_prompt();
+ cst_session_append(a->session, &sys);
+ }
+ return 0;
+}
+
+int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz) {
+ if (!a)
+ return -1;
+ if (cst_agent_store_ensure(a->cfg.workspace_root) != 0)
+ return -1;
+ char id[128];
+ if (cst_agent_store_new_id(id, sizeof(id)) != 0)
+ return -1;
+ char path[4096];
+ if (cst_agent_store_path_for_id(a->cfg.workspace_root, id, path,
+ sizeof(path)) != 0)
+ return -1;
+ if (agent_reopen_session(a, path) != 0)
+ return -1;
+ cst_agent_store_set_current(a->cfg.workspace_root, id);
+ if (id_out && id_sz)
+ snprintf(id_out, id_sz, "%s", id);
+ return 0;
+}
+
+int cst_agent_switch_session(cst_agent_t *a, const char *id) {
+ if (!a || !id || !id[0])
+ return -1;
+ char path[4096];
+ if (cst_agent_store_path_for_id(a->cfg.workspace_root, id, path,
+ sizeof(path)) != 0)
+ return -1;
+ if (agent_reopen_session(a, path) != 0)
+ return -1;
+ /* Strip .jsonl for CURRENT id */
+ char bare[128];
+ snprintf(bare, sizeof(bare), "%s", id);
+ size_t n = strlen(bare);
+ if (n > 6 && strcmp(bare + n - 6, ".jsonl") == 0)
+ bare[n - 6] = 0;
+ cst_agent_store_set_current(a->cfg.workspace_root, bare);
+ return 0;
+}
+
+typedef struct {
+ char *buf;
+ size_t len;
+ size_t cap;
+} acc_t;
+
+static void acc_feed(const char *data, size_t len, void *ud) {
+ acc_t *a = ud;
+ if (!data || !len)
+ return;
+ if (a->len + len + 1 > a->cap) {
+ size_t nc = a->cap ? a->cap * 2 : 4096;
+ while (nc < a->len + len + 1)
+ nc *= 2;
+ char *nb = realloc(a->buf, nc);
+ if (!nb)
+ return;
+ a->buf = nb;
+ a->cap = nc;
+ }
+ memcpy(a->buf + a->len, data, len);
+ a->len += len;
+ a->buf[a->len] = 0;
+}
+
+int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz) {
+ if (err && err_sz)
+ err[0] = 0;
+ if (!a || !a->session)
+ return -1;
+
+ int n = cst_session_count(a->session);
+ if (n <= 1) {
+ if (err)
+ snprintf(err, err_sz, "nothing to compact");
+ return -1;
+ }
+
+ /* Build a compact request: system + one user blob of prior turns. */
+ size_t blob_cap = 8192, blob_len = 0;
+ char *blob = malloc(blob_cap);
+ if (!blob)
+ return -1;
+ const char *hdr =
+ "请将下列会话压缩为简洁中文摘要,保留:目标、关键路径、已做决策、"
+ "未完成事项。不要调用工具,直接输出摘要正文。\n\n---\n";
+ size_t hlen = strlen(hdr);
+ memcpy(blob, hdr, hlen);
+ blob_len = hlen;
+ blob[blob_len] = 0;
+
+ for (int i = 0; i < n; i++) {
+ const cst_message_t *m = cst_session_get(a->session, i);
+ if (!m || m->role == CST_ROLE_SYSTEM)
+ continue;
+ const char *role = m->role == CST_ROLE_USER ? "user"
+ : m->role == CST_ROLE_ASSISTANT ? "assistant"
+ : "tool";
+ const char *c = m->content ? m->content : "";
+ size_t cl = strlen(c);
+ if (cl > 2000)
+ cl = 2000;
+ char line[128];
+ int ln = snprintf(line, sizeof(line), "\n[%s]\n", role);
+ while (blob_len + (size_t)ln + cl + 8 > blob_cap) {
+ blob_cap *= 2;
+ char *nb = realloc(blob, blob_cap);
+ if (!nb) {
+ free(blob);
+ return -1;
+ }
+ blob = nb;
+ }
+ memcpy(blob + blob_len, line, (size_t)ln);
+ blob_len += (size_t)ln;
+ memcpy(blob + blob_len, c, cl);
+ blob_len += cl;
+ blob[blob_len] = 0;
+ if (blob_len > 120000)
+ break;
+ }
+
+ cst_message_t reqs[2];
+ memset(reqs, 0, sizeof(reqs));
+ reqs[0].role = CST_ROLE_SYSTEM;
+ reqs[0].content = (char *)"You summarize coding-agent sessions. No tools.";
+ reqs[1].role = CST_ROLE_USER;
+ reqs[1].content = blob;
+
+ acc_t acc = {0};
+ cst_llm_request_t req = {0};
+ req.api_base = a->cfg.api_base;
+ req.api_key = a->cfg.api_key;
+ req.model = a->cfg.model;
+ req.msgs = reqs;
+ req.nmsgs = 2;
+ req.on_delta = acc_feed;
+ req.userdata = &acc;
+
+ char lerr[512];
+ int rc = cst_llm_chat_stream(&req, lerr, sizeof(lerr));
+ free(blob);
+ if (rc != 0 || !acc.buf || acc.len == 0) {
+ free(acc.buf);
+ if (err)
+ snprintf(err, err_sz, "compact llm: %s", lerr[0] ? lerr : "empty");
+ return -1;
+ }
+
+ /* Strip ... blocks from summary. */
+ {
+ char *src = acc.buf;
+ char *dst = acc.buf;
+ while (*src) {
+ if (strncmp(src, "", 7) == 0) {
+ char *end = strstr(src, " ");
+ if (end) {
+ src = end + 8;
+ continue;
+ }
+ }
+ *dst++ = *src++;
+ }
+ *dst = 0;
+ acc.len = (size_t)(dst - acc.buf);
+ while (acc.len > 0 &&
+ (acc.buf[acc.len - 1] == '\n' || acc.buf[acc.len - 1] == ' '))
+ acc.buf[--acc.len] = 0;
+ }
+ if (acc.len < 8) {
+ free(acc.buf);
+ if (err)
+ snprintf(err, err_sz, "compact produced empty summary");
+ return -1;
+ }
+
+ cst_message_t keep[2];
+ memset(keep, 0, sizeof(keep));
+ keep[0].role = CST_ROLE_SYSTEM;
+ keep[0].content = (char *)cst_agent_system_prompt();
+ keep[1].role = CST_ROLE_USER;
+ size_t slen = acc.len + 64;
+ char *summary = malloc(slen);
+ if (!summary) {
+ free(acc.buf);
+ return -1;
+ }
+ snprintf(summary, slen, "[会话已压缩 /compact]\n%s", acc.buf);
+ free(acc.buf);
+ keep[1].content = summary;
+
+ if (cst_session_rewrite(a->session, keep, 2) != 0) {
+ free(summary);
+ if (err)
+ snprintf(err, err_sz, "rewrite session failed");
+ return -1;
+ }
+ free(summary);
+ return 0;
+}
diff --git a/src/agent_store.c b/src/agent_store.c
new file mode 100644
index 0000000..852f0a0
--- /dev/null
+++ b/src/agent_store.c
@@ -0,0 +1,189 @@
+#define _DEFAULT_SOURCE
+
+#include "cst_agent.h"
+#include "os_compat.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef _WIN32
+#include
+#define OS_ACCESS _access
+#define OS_F_OK 0
+#else
+#include
+#define OS_ACCESS access
+#define OS_F_OK F_OK
+#endif
+
+int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz) {
+ if (!workspace || !out || out_sz < 16)
+ return -1;
+ return os_path_join(out, out_sz, workspace, ".agent/sessions");
+}
+
+int cst_agent_store_ensure(const char *workspace) {
+ char base[OS_PATH_MAX];
+ char sess[OS_PATH_MAX];
+ if (cst_agent_store_dir(workspace, sess, sizeof(sess)) != 0)
+ return -1;
+ if (os_path_join(base, sizeof(base), workspace, ".agent") != 0)
+ return -1;
+ if (os_mkdir(base, 0755) != 0)
+ return -1;
+ if (os_mkdir(sess, 0755) != 0)
+ return -1;
+ return 0;
+}
+
+static void make_id(char *out, size_t n) {
+ time_t now = time(NULL);
+ struct tm tm;
+ os_localtime(&now, &tm);
+ unsigned r = (unsigned)(os_getpid() ^ (unsigned)now);
+ snprintf(out, n, "%04d%02d%02d-%02d%02d%02d-%04x", tm.tm_year + 1900,
+ tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec,
+ r & 0xffff);
+}
+
+int cst_agent_store_new_id(char *id_out, size_t id_sz) {
+ if (!id_out || id_sz < 24)
+ return -1;
+ make_id(id_out, id_sz);
+ return 0;
+}
+
+int cst_agent_store_path_for_id(const char *workspace, const char *id,
+ char *out, size_t out_sz) {
+ if (!workspace || !id || !out)
+ return -1;
+ /* Accept bare id or full path. */
+ if (strchr(id, '/') || strchr(id, '\\')) {
+ snprintf(out, out_sz, "%s", id);
+ return 0;
+ }
+ char dir[OS_PATH_MAX];
+ char leaf[160];
+ if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
+ return -1;
+ snprintf(leaf, sizeof(leaf), "%s.jsonl", id);
+ return os_path_join(out, out_sz, dir, leaf);
+}
+
+int cst_agent_store_set_current(const char *workspace, const char *id) {
+ if (cst_agent_store_ensure(workspace) != 0)
+ return -1;
+ char dir[OS_PATH_MAX];
+ char path[OS_PATH_MAX];
+ if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
+ return -1;
+ if (os_path_join(path, sizeof(path), dir, "CURRENT") != 0)
+ return -1;
+ FILE *fp = fopen(path, "w");
+ if (!fp)
+ return -1;
+ fprintf(fp, "%s\n", id);
+ fclose(fp);
+ return 0;
+}
+
+int cst_agent_store_get_current(const char *workspace, char *id_out,
+ size_t id_sz) {
+ char dir[OS_PATH_MAX];
+ char path[OS_PATH_MAX];
+ if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
+ return -1;
+ if (os_path_join(path, sizeof(path), dir, "CURRENT") != 0)
+ return -1;
+ FILE *fp = fopen(path, "r");
+ if (!fp)
+ return -1;
+ if (!fgets(id_out, (int)id_sz, fp)) {
+ fclose(fp);
+ return -1;
+ }
+ fclose(fp);
+ size_t n = strlen(id_out);
+ while (n > 0 && (id_out[n - 1] == '\n' || id_out[n - 1] == '\r'))
+ id_out[--n] = 0;
+ return id_out[0] ? 0 : -1;
+}
+
+int cst_agent_store_resolve_default(const char *workspace, char *path_out,
+ size_t path_sz, char *id_out,
+ size_t id_sz) {
+ if (cst_agent_store_ensure(workspace) != 0)
+ return -1;
+ char id[128];
+ if (cst_agent_store_get_current(workspace, id, sizeof(id)) == 0) {
+ char p[OS_PATH_MAX];
+ if (cst_agent_store_path_for_id(workspace, id, p, sizeof(p)) == 0 &&
+ OS_ACCESS(p, OS_F_OK) == 0) {
+ snprintf(path_out, path_sz, "%s", p);
+ if (id_out && id_sz)
+ snprintf(id_out, id_sz, "%s", id);
+ return 0;
+ }
+ }
+ if (cst_agent_store_new_id(id, sizeof(id)) != 0)
+ return -1;
+ if (cst_agent_store_path_for_id(workspace, id, path_out, path_sz) != 0)
+ return -1;
+ cst_agent_store_set_current(workspace, id);
+ if (id_out && id_sz)
+ snprintf(id_out, id_sz, "%s", id);
+ return 0;
+}
+
+int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids) {
+ if (!workspace || !ids || max_ids <= 0)
+ return 0;
+ char dir[OS_PATH_MAX];
+ if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
+ return 0;
+ DIR *d = opendir(dir);
+ if (!d)
+ return 0;
+ typedef struct {
+ char id[128];
+ time_t mtime;
+ } ent_t;
+ ent_t *ents = calloc((size_t)max_ids * 4, sizeof(ent_t));
+ int n = 0, cap = max_ids * 4;
+ struct dirent *de;
+ while ((de = readdir(d)) != NULL) {
+ size_t len = strlen(de->d_name);
+ if (len < 7 || strcmp(de->d_name + len - 6, ".jsonl") != 0)
+ continue;
+ if (n >= cap)
+ break;
+ snprintf(ents[n].id, sizeof(ents[n].id), "%.*s", (int)(len - 6),
+ de->d_name);
+ char full[OS_PATH_MAX];
+ if (os_path_join(full, sizeof(full), dir, de->d_name) != 0)
+ continue;
+ struct stat st;
+ ents[n].mtime = (stat(full, &st) == 0) ? st.st_mtime : 0;
+ n++;
+ }
+ closedir(d);
+ for (int i = 0; i < n; i++) {
+ for (int j = i + 1; j < n; j++) {
+ if (ents[j].mtime > ents[i].mtime) {
+ ent_t tmp = ents[i];
+ ents[i] = ents[j];
+ ents[j] = tmp;
+ }
+ }
+ }
+ int out_n = n < max_ids ? n : max_ids;
+ for (int i = 0; i < out_n; i++)
+ snprintf(ids[i], 128, "%s", ents[i].id);
+ free(ents);
+ return out_n;
+}
diff --git a/src/creds.c b/src/creds.c
new file mode 100644
index 0000000..241a12f
--- /dev/null
+++ b/src/creds.c
@@ -0,0 +1,140 @@
+#define _DEFAULT_SOURCE
+
+#include "cst_agent.h"
+#include "os_compat.h"
+
+#include
+#include
+#include
+#include
+
+#ifdef _WIN32
+#include
+#else
+#include
+#endif
+
+/* Config dir via os_compat: ~/.config/cst-agent or %APPDATA%\cst-agent */
+
+static int credentials_path(char *out, size_t n) {
+ char dir[OS_PATH_MAX];
+ if (os_config_dir(dir, sizeof(dir)) != 0)
+ return -1;
+ return os_path_join(out, n, dir, "credentials");
+}
+
+int cst_creds_load_api_key(char *out, size_t out_sz) {
+ if (!out || out_sz < 2)
+ return -1;
+ out[0] = 0;
+ char path[OS_PATH_MAX];
+ if (credentials_path(path, sizeof(path)) != 0)
+ return -1;
+ FILE *fp = fopen(path, "r");
+ if (!fp)
+ return -1;
+ char line[1024];
+ while (fgets(line, sizeof(line), fp)) {
+ if (strncmp(line, "api_key=", 8) != 0)
+ continue;
+ char *v = line + 8;
+ size_t len = strlen(v);
+ while (len > 0 && (v[len - 1] == '\n' || v[len - 1] == '\r'))
+ v[--len] = 0;
+ if (len == 0 || len + 1 > out_sz) {
+ fclose(fp);
+ return -1;
+ }
+ memcpy(out, v, len + 1);
+ fclose(fp);
+ return 0;
+ }
+ fclose(fp);
+ return -1;
+}
+
+int cst_creds_save_api_key(const char *key) {
+ if (!key || !key[0])
+ return -1;
+ char dir[OS_PATH_MAX], path[OS_PATH_MAX];
+ if (os_config_dir(dir, sizeof(dir)) != 0)
+ return -1;
+ if (os_mkdir(dir, 0700) != 0)
+ return -1;
+ if (credentials_path(path, sizeof(path)) != 0)
+ return -1;
+ FILE *fp = fopen(path, "w");
+ if (!fp)
+ return -1;
+ fprintf(fp, "api_key=%s\n", key);
+ fprintf(fp, "api_base=https://api.minimax.cn/v1\n");
+ fprintf(fp, "model=MiniMax-M2.7\n");
+ fclose(fp);
+ (void)os_chmod_private(path);
+ return 0;
+}
+
+/* Zenity on Unix; stdin prompt elsewhere / when zenity missing. */
+int cst_creds_prompt_api_key(char *out, size_t out_sz) {
+ if (!out || out_sz < 2)
+ return -1;
+ out[0] = 0;
+
+#ifndef _WIN32
+ FILE *fp = popen(
+ "zenity --password --title='CST-Agent / MiniMax' "
+ "--text='请输入 MiniMax API Key(将保存到配置目录 credentials)' "
+ "2>/dev/null",
+ "r");
+ if (fp) {
+ if (fgets(out, (int)out_sz, fp)) {
+ int st = pclose(fp);
+ size_t len = strlen(out);
+ while (len > 0 && (out[len - 1] == '\n' || out[len - 1] == '\r'))
+ out[--len] = 0;
+ if (st == 0 && len > 0)
+ return 0;
+ } else {
+ pclose(fp);
+ }
+ out[0] = 0;
+ }
+#endif
+
+ /* Fallback: console prompt (headless / Windows / no zenity). */
+ fprintf(stderr, "CST-Agent: enter MiniMax API Key (saved to config dir): ");
+ fflush(stderr);
+ if (!fgets(out, (int)out_sz, stdin)) {
+ out[0] = 0;
+ return -1;
+ }
+ size_t len = strlen(out);
+ while (len > 0 && (out[len - 1] == '\n' || out[len - 1] == '\r'))
+ out[--len] = 0;
+ if (len == 0) {
+ out[0] = 0;
+ return -1;
+ }
+ return 0;
+}
+
+int cst_creds_ensure_api_key(char *out, size_t out_sz) {
+ const char *env = getenv("CST_API_KEY");
+ if (!env || !env[0])
+ env = getenv("MINIMAX_API_KEY");
+ if (!env || !env[0])
+ env = getenv("OPENAI_API_KEY");
+ if (env && env[0]) {
+ snprintf(out, out_sz, "%s", env);
+ return 0;
+ }
+ if (cst_creds_load_api_key(out, out_sz) == 0 && out[0])
+ return 0;
+ if (cst_creds_prompt_api_key(out, out_sz) != 0)
+ return -1;
+ if (cst_creds_save_api_key(out) != 0)
+ fprintf(stderr, "warning: failed to save credentials file\n");
+ else
+ fprintf(stderr, "saved API key to config directory\n");
+ return 0;
+}
diff --git a/src/ctl_parse.c b/src/ctl_parse.c
new file mode 100644
index 0000000..4bf6262
--- /dev/null
+++ b/src/ctl_parse.c
@@ -0,0 +1,419 @@
+#include "ctl.h"
+
+#include
+#include
+#include
+#include
+
+typedef enum {
+ ST_TEXT = 0, /* outside a call; accumulate text lines */
+ ST_LINE, /* collecting a line (call/arg/end or text) */
+ ST_ARG_BODY, /* reading nbytes of arg body */
+ ST_ERROR
+} ctl_state_t;
+
+struct ctl_parser {
+ ctl_on_event_fn on_event;
+ void *userdata;
+ ctl_state_t state;
+ char line[CTL_MAX_LINE];
+ size_t line_len;
+ ctl_tool_call_t call;
+ int in_call;
+ /* current arg being filled */
+ char arg_key[CTL_MAX_KEY];
+ char *arg_buf;
+ size_t arg_cap;
+ size_t arg_got;
+ size_t arg_need;
+ size_t pending;
+ size_t pending_peak;
+ char errbuf[256];
+};
+
+static void bump_pending(ctl_parser_t *p, size_t n) {
+ p->pending = n;
+ if (n > p->pending_peak)
+ p->pending_peak = n;
+}
+
+static void emit(ctl_parser_t *p, ctl_event_t *ev) {
+ if (p->on_event)
+ p->on_event(ev, p->userdata);
+}
+
+static void fail(ctl_parser_t *p, const char *msg) {
+ snprintf(p->errbuf, sizeof(p->errbuf), "%s", msg);
+ p->state = ST_ERROR;
+ ctl_event_t ev = {0};
+ ev.type = CTL_EV_ERROR;
+ ev.error = p->errbuf;
+ emit(p, &ev);
+}
+
+/* free args in current call */
+static void clear_call_args(ctl_tool_call_t *c) {
+ for (int i = 0; i < c->nargs; i++) {
+ free(c->args[i].value);
+ c->args[i].value = NULL;
+ c->args[i].value_len = 0;
+ }
+ c->nargs = 0;
+ c->id[0] = 0;
+ c->name[0] = 0;
+}
+
+static void reset_arg_body(ctl_parser_t *p) {
+ free(p->arg_buf);
+ p->arg_buf = NULL;
+ p->arg_cap = 0;
+ p->arg_got = 0;
+ p->arg_need = 0;
+ p->arg_key[0] = 0;
+}
+
+ctl_parser_t *ctl_parser_create(ctl_on_event_fn on_event, void *userdata) {
+ ctl_parser_t *p = calloc(1, sizeof(*p));
+ if (!p)
+ return NULL;
+ p->on_event = on_event;
+ p->userdata = userdata;
+ p->state = ST_TEXT;
+ return p;
+}
+
+void ctl_parser_destroy(ctl_parser_t *p) {
+ if (!p)
+ return;
+ clear_call_args(&p->call);
+ reset_arg_body(p);
+ free(p);
+}
+
+void ctl_parser_reset(ctl_parser_t *p) {
+ if (!p)
+ return;
+ clear_call_args(&p->call);
+ reset_arg_body(p);
+ p->state = ST_TEXT;
+ p->line_len = 0;
+ p->in_call = 0;
+ p->pending = 0;
+ /* keep pending_peak for experiment */
+}
+
+size_t ctl_parser_pending(const ctl_parser_t *p) {
+ return p ? p->pending : 0;
+}
+
+size_t ctl_parser_pending_peak(const ctl_parser_t *p) {
+ return p ? p->pending_peak : 0;
+}
+
+static int starts_with(const char *s, const char *pfx) {
+ return strncmp(s, pfx, strlen(pfx)) == 0;
+}
+
+static int parse_call_line(ctl_parser_t *p, const char *line) {
+ /* call */
+ const char *s = line + 5;
+ while (*s == ' ' || *s == '\t')
+ s++;
+ if (!*s)
+ return -1;
+ const char *sp = strchr(s, ' ');
+ if (!sp)
+ return -1;
+ size_t idlen = (size_t)(sp - s);
+ if (idlen == 0 || idlen >= CTL_MAX_ID)
+ return -1;
+ while (*sp == ' ' || *sp == '\t')
+ sp++;
+ if (!*sp)
+ return -1;
+ size_t nlen = strlen(sp);
+ if (nlen == 0 || nlen >= CTL_MAX_NAME)
+ return -1;
+ for (size_t i = 0; i < nlen; i++) {
+ if (!(isalnum((unsigned char)sp[i]) || sp[i] == '_' || sp[i] == '-'))
+ return -1;
+ }
+ clear_call_args(&p->call);
+ memcpy(p->call.id, s, idlen);
+ p->call.id[idlen] = 0;
+ memcpy(p->call.name, sp, nlen);
+ p->call.name[nlen] = 0;
+ p->in_call = 1;
+ ctl_event_t ev = {0};
+ ev.type = CTL_EV_CALL_OPEN;
+ ev.call = &p->call;
+ emit(p, &ev);
+ return 0;
+}
+
+static int finish_arg(ctl_parser_t *p);
+
+static int parse_arg_header(ctl_parser_t *p, const char *line) {
+ /* Forms:
+ * arg → length-prefixed body follows
+ * arg → value is rest of line (line-mode, confirmed now)
+ */
+ const char *s = line + 4;
+ while (*s == ' ' || *s == '\t')
+ s++;
+ if (!*s)
+ return -1;
+ const char *sp = strchr(s, ' ');
+ if (!sp)
+ return -1;
+ size_t klen = (size_t)(sp - s);
+ if (klen == 0 || klen >= CTL_MAX_KEY)
+ return -1;
+ while (*sp == ' ' || *sp == '\t')
+ sp++;
+ if (!*sp)
+ return -1;
+ if (p->call.nargs >= CTL_MAX_ARGS)
+ return -1;
+
+ char *end = NULL;
+ unsigned long nbytes = strtoul(sp, &end, 10);
+ int length_prefixed = (end != sp && (*end == '\0' || *end == ' ' || *end == '\t'));
+ if (length_prefixed && end && *end != '\0') {
+ while (*end == ' ' || *end == '\t')
+ end++;
+ if (*end != '\0')
+ length_prefixed = 0; /* e.g. "9foo" or value starting with digits+text */
+ }
+
+ memcpy(p->arg_key, s, klen);
+ p->arg_key[klen] = 0;
+
+ if (!length_prefixed) {
+ /* line-mode: entire remainder is the value */
+ size_t vlen = strlen(sp);
+ if (vlen > CTL_MAX_ARG_BYTES)
+ return -1;
+ free(p->arg_buf);
+ p->arg_buf = malloc(vlen + 1);
+ if (!p->arg_buf)
+ return -2;
+ memcpy(p->arg_buf, sp, vlen);
+ p->arg_buf[vlen] = 0;
+ p->arg_need = vlen;
+ p->arg_got = vlen;
+ p->arg_cap = vlen;
+ finish_arg(p);
+ return 0;
+ }
+
+ if (nbytes > CTL_MAX_ARG_BYTES)
+ return -1;
+ p->arg_need = (size_t)nbytes;
+ p->arg_got = 0;
+ free(p->arg_buf);
+ p->arg_buf = NULL;
+ p->arg_cap = 0;
+ if (p->arg_need > 0) {
+ p->arg_buf = malloc(p->arg_need + 1);
+ if (!p->arg_buf)
+ return -2;
+ p->arg_cap = p->arg_need;
+ }
+ p->state = ST_ARG_BODY;
+ bump_pending(p, p->arg_need);
+ return 0;
+}
+
+static int finish_arg(ctl_parser_t *p) {
+ int i = p->call.nargs;
+ snprintf(p->call.args[i].key, CTL_MAX_KEY, "%s", p->arg_key);
+ p->call.args[i].value = p->arg_buf;
+ p->call.args[i].value_len = p->arg_got;
+ if (p->arg_buf)
+ p->arg_buf[p->arg_got] = 0;
+ p->arg_buf = NULL; /* ownership moved */
+ p->call.nargs++;
+ p->arg_need = 0;
+ p->arg_got = 0;
+ p->arg_key[0] = 0;
+ p->state = ST_LINE;
+ bump_pending(p, 0);
+ ctl_event_t ev = {0};
+ ev.type = CTL_EV_ARG;
+ ev.call = &p->call;
+ emit(p, &ev);
+ return 0;
+}
+
+static void emit_text_line(ctl_parser_t *p, const char *line, size_t len) {
+ /* include newline as confirmed text */
+ char tmp[CTL_MAX_LINE + 1];
+ if (len + 1 >= sizeof(tmp))
+ len = sizeof(tmp) - 2;
+ memcpy(tmp, line, len);
+ tmp[len] = '\n';
+ tmp[len + 1] = 0;
+ ctl_event_t ev = {0};
+ ev.type = CTL_EV_TEXT;
+ ev.text = tmp;
+ ev.text_len = len + 1;
+ emit(p, &ev);
+}
+
+static int is_fence_line(const char *line) {
+ if (strncmp(line, "```", 3) == 0)
+ return 1;
+ if (strncmp(line, "~~~", 3) == 0)
+ return 1;
+ return 0;
+}
+
+static int handle_line(ctl_parser_t *p, const char *line) {
+ /* Ignore markdown fences so models can wrap CTL in code blocks. */
+ if (is_fence_line(line)) {
+ bump_pending(p, 0);
+ return 0;
+ }
+ /* Ignore blank lines between CTL frames. */
+ if (line[0] == '\0') {
+ bump_pending(p, 0);
+ return 0;
+ }
+
+ if (!p->in_call) {
+ if (starts_with(line, "call ")) {
+ if (parse_call_line(p, line) != 0) {
+ fail(p, "invalid call line");
+ return -1;
+ }
+ bump_pending(p, 0);
+ return 0;
+ }
+ /* confirmed text line */
+ emit_text_line(p, line, strlen(line));
+ bump_pending(p, 0);
+ return 0;
+ }
+
+ /* in call */
+ if (starts_with(line, "arg ")) {
+ int rc = parse_arg_header(p, line);
+ if (rc == -2) {
+ fail(p, "oom");
+ return -1;
+ }
+ if (rc != 0) {
+ fail(p, "invalid arg header");
+ return -1;
+ }
+ if (p->arg_need == 0)
+ finish_arg(p);
+ return 0;
+ }
+ if (strcmp(line, "end") == 0) {
+ ctl_event_t ev = {0};
+ ev.type = CTL_EV_CALL_READY;
+ ev.call = &p->call;
+ emit(p, &ev);
+ clear_call_args(&p->call);
+ p->in_call = 0;
+ bump_pending(p, 0);
+ p->state = ST_LINE;
+ return 0;
+ }
+ fail(p, "unexpected line inside call");
+ return -1;
+}
+
+ctl_status_t ctl_parser_feed(ctl_parser_t *p, const char *data, size_t len) {
+ if (!p)
+ return CTL_ERR_CONTRACT;
+ if (p->state == ST_ERROR)
+ return CTL_ERR_CONTRACT;
+
+ size_t i = 0;
+ while (i < len) {
+ if (p->state == ST_ARG_BODY) {
+ size_t left = p->arg_need - p->arg_got;
+ size_t take = len - i;
+ if (take > left)
+ take = left;
+ if (take > 0) {
+ memcpy(p->arg_buf + p->arg_got, data + i, take);
+ p->arg_got += take;
+ i += take;
+ bump_pending(p, p->arg_need - p->arg_got);
+ }
+ if (p->arg_got == p->arg_need) {
+ finish_arg(p);
+ /* continue with remaining bytes in LINE mode */
+ }
+ continue;
+ }
+
+ /* line accumulation */
+ p->state = ST_LINE;
+ unsigned char c = (unsigned char)data[i++];
+ if (c == '\r')
+ continue; /* normalize CRLF */
+ if (c == '\n') {
+ p->line[p->line_len] = 0;
+ if (handle_line(p, p->line) != 0)
+ return CTL_ERR_CONTRACT;
+ p->line_len = 0;
+ if (p->state == ST_ARG_BODY && p->arg_need == 0) {
+ /* already finished empty arg */
+ }
+ continue;
+ }
+ if (p->line_len + 1 >= CTL_MAX_LINE) {
+ fail(p, "line too long");
+ return CTL_ERR_LIMIT;
+ }
+ p->line[p->line_len++] = (char)c;
+ bump_pending(p, p->line_len);
+ }
+ return CTL_OK;
+}
+
+ctl_status_t ctl_parser_finish(ctl_parser_t *p) {
+ if (!p)
+ return CTL_ERR_CONTRACT;
+ if (p->state == ST_ERROR)
+ return CTL_ERR_CONTRACT;
+ /* Mid-argument body cannot be confirmed at EOF. */
+ if (p->state == ST_ARG_BODY) {
+ fail(p, "incomplete arg body at EOF");
+ return CTL_ERR_CONTRACT;
+ }
+ /* Trailing text without final newline is still confirmed text. */
+ if (!p->in_call && p->line_len > 0) {
+ p->line[p->line_len] = 0;
+ emit_text_line(p, p->line, p->line_len);
+ p->line_len = 0;
+ }
+ /* EOF confirms an open call whose args are complete (implicit end).
+ * Matches paper EOF adequacy: stream end closes the frontier. */
+ if (p->in_call) {
+ if (p->line_len > 0) {
+ p->line[p->line_len] = 0;
+ /* leftover line must be end or fence/blank */
+ if (strcmp(p->line, "end") == 0 || is_fence_line(p->line) ||
+ p->line[0] == '\0') {
+ p->line_len = 0;
+ } else {
+ fail(p, "unexpected trailing line in call at EOF");
+ return CTL_ERR_CONTRACT;
+ }
+ }
+ ctl_event_t ev = {0};
+ ev.type = CTL_EV_CALL_READY;
+ ev.call = &p->call;
+ emit(p, &ev);
+ clear_call_args(&p->call);
+ p->in_call = 0;
+ }
+ bump_pending(p, 0);
+ return CTL_OK;
+}
diff --git a/src/json_baseline.c b/src/json_baseline.c
new file mode 100644
index 0000000..3dd305e
--- /dev/null
+++ b/src/json_baseline.c
@@ -0,0 +1,145 @@
+#include "cst_agent.h"
+
+#include
+#include
+
+/* Minimal JSON tool object: {"name":"...","arguments":{...}}
+ * Buffer-until-valid baseline (NOT bounded CST). */
+
+static const char *skip_ws(const char *p, const char *end) {
+ while (p < end && (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t'))
+ p++;
+ return p;
+}
+
+/* Find matching closing brace/bracket starting at open pos. Returns end ptr or
+ * NULL if incomplete. Handles strings/escapes. */
+static const char *match_obj(const char *start, const char *end) {
+ if (start >= end || *start != '{')
+ return NULL;
+ int depth = 0;
+ int in_str = 0;
+ for (const char *p = start; p < end; p++) {
+ if (in_str) {
+ if (*p == '\\' && p + 1 < end) {
+ p++;
+ continue;
+ }
+ if (*p == '"')
+ in_str = 0;
+ continue;
+ }
+ if (*p == '"') {
+ in_str = 1;
+ continue;
+ }
+ if (*p == '{')
+ depth++;
+ else if (*p == '}') {
+ depth--;
+ if (depth == 0)
+ return p + 1;
+ }
+ }
+ return NULL; /* incomplete */
+}
+
+static int extract_string(const char *obj, const char *end, const char *key,
+ char *out, size_t out_sz) {
+ char pat[128];
+ snprintf(pat, sizeof(pat), "\"%s\"", key);
+ const char *k = obj;
+ while (k && k < end) {
+ k = strstr(k, pat);
+ if (!k || k >= end)
+ return -1;
+ /* ensure key boundary */
+ const char *colon = k + strlen(pat);
+ colon = skip_ws(colon, end);
+ if (colon >= end || *colon != ':') {
+ k++;
+ continue;
+ }
+ colon++;
+ colon = skip_ws(colon, end);
+ if (colon >= end || *colon != '"')
+ return -1;
+ colon++;
+ size_t o = 0;
+ for (const char *p = colon; p < end && o + 1 < out_sz; p++) {
+ if (*p == '\\' && p + 1 < end) {
+ p++;
+ if (*p == 'n')
+ out[o++] = '\n';
+ else
+ out[o++] = *p;
+ continue;
+ }
+ if (*p == '"') {
+ out[o] = 0;
+ return 0;
+ }
+ out[o++] = *p;
+ }
+ return -1;
+ }
+ return -1;
+}
+
+int cst_json_try_parse_tool(const char *buf, size_t len, cst_json_tool_t *out) {
+ if (!buf || !out)
+ return 0;
+ memset(out, 0, sizeof(*out));
+ const char *end = buf + len;
+ const char *p = buf;
+ while (p < end) {
+ p = skip_ws(p, end);
+ if (p >= end)
+ break;
+ if (*p != '{') {
+ p++;
+ continue;
+ }
+ const char *obj_end = match_obj(p, end);
+ if (!obj_end)
+ return 0; /* incomplete JSON — starvation failure mode */
+ if (extract_string(p, obj_end, "name", out->name, sizeof(out->name)) !=
+ 0) {
+ p++;
+ continue;
+ }
+ /* arguments: object as raw substring */
+ const char *akey = strstr(p, "\"arguments\"");
+ if (!akey || akey >= obj_end) {
+ p = obj_end;
+ continue;
+ }
+ const char *colon = strchr(akey + 11, ':');
+ if (!colon || colon >= obj_end)
+ return 0;
+ colon++;
+ colon = skip_ws(colon, obj_end);
+ if (colon >= obj_end)
+ return 0;
+ if (*colon == '{') {
+ const char *ae = match_obj(colon, obj_end);
+ if (!ae)
+ return 0;
+ size_t n = (size_t)(ae - colon);
+ if (n >= sizeof(out->arguments_json))
+ return 0;
+ memcpy(out->arguments_json, colon, n);
+ out->arguments_json[n] = 0;
+ } else if (*colon == '"') {
+ if (extract_string(akey, obj_end, "arguments", out->arguments_json,
+ sizeof(out->arguments_json)) != 0)
+ return 0;
+ } else {
+ p = obj_end;
+ continue;
+ }
+ out->ok = 1;
+ return 1;
+ }
+ return 0;
+}
diff --git a/src/llm_openai.c b/src/llm_openai.c
new file mode 100644
index 0000000..06c3348
--- /dev/null
+++ b/src/llm_openai.c
@@ -0,0 +1,786 @@
+#include "cst_agent.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#define TC_SLOTS 8
+#define TC_ID_MAX 128
+#define TC_NAME_MAX 128
+#define TC_ARGS_MAX 65536
+
+typedef struct {
+ int used;
+ int index;
+ char id[TC_ID_MAX];
+ char name[TC_NAME_MAX];
+ char *args;
+ size_t args_len;
+ size_t args_cap;
+} tc_slot_t;
+
+typedef struct {
+ cst_llm_on_delta_fn on_delta;
+ cst_llm_on_native_tool_fn on_native_tool;
+ void *userdata;
+ char *buf;
+ size_t len;
+ size_t cap;
+ int in_data;
+ int in_reason; /* wrapping reasoning_content as for the agent */
+ tc_slot_t slots[TC_SLOTS];
+} sse_acc_t;
+
+static void emit_raw(sse_acc_t *a, const char *s, size_t n) {
+ if (a && a->on_delta && s && n)
+ a->on_delta(s, n, a->userdata);
+}
+
+static const char *find_json_string_key(const char *payload, const char *key) {
+ /* Find "key" that is a full JSON key, not a suffix of another key
+ * (e.g. avoid matching content inside audio_content / reasoning_content). */
+ size_t klen = strlen(key);
+ const char *p = payload;
+ while ((p = strstr(p, key)) != NULL) {
+ if (p > payload) {
+ unsigned char prev = (unsigned char)p[-1];
+ if (prev == '_' || isalnum(prev)) {
+ p += klen;
+ continue;
+ }
+ }
+ return p;
+ }
+ return NULL;
+}
+
+/* Decode one JSON string value starting after the opening quote into out. */
+static size_t decode_json_string(const char *colon_quote, char *out,
+ size_t out_sz) {
+ size_t o = 0;
+ for (const char *p = colon_quote; *p && o + 4 < out_sz; p++) {
+ if (*p == '\\' && p[1]) {
+ p++;
+ if (*p == 'n')
+ out[o++] = '\n';
+ else if (*p == 'r')
+ out[o++] = '\r';
+ else if (*p == 't')
+ out[o++] = '\t';
+ else if (*p == '"' || *p == '\\' || *p == '/')
+ out[o++] = *p;
+ else if (*p == 'u' && p[1] && p[2] && p[3] && p[4]) {
+ unsigned cp = 0;
+ int ok = 1;
+ for (int i = 1; i <= 4; i++) {
+ char h = p[i];
+ cp <<= 4;
+ if (h >= '0' && h <= '9')
+ cp |= (unsigned)(h - '0');
+ else if (h >= 'a' && h <= 'f')
+ cp |= (unsigned)(h - 'a' + 10);
+ else if (h >= 'A' && h <= 'F')
+ cp |= (unsigned)(h - 'A' + 10);
+ else {
+ ok = 0;
+ break;
+ }
+ }
+ p += 4;
+ if (!ok) {
+ out[o++] = '?';
+ continue;
+ }
+ if (cp < 0x80)
+ out[o++] = (char)cp;
+ else if (cp < 0x800) {
+ out[o++] = (char)(0xC0 | (cp >> 6));
+ out[o++] = (char)(0x80 | (cp & 0x3F));
+ } else {
+ out[o++] = (char)(0xE0 | (cp >> 12));
+ out[o++] = (char)(0x80 | ((cp >> 6) & 0x3F));
+ out[o++] = (char)(0x80 | (cp & 0x3F));
+ }
+ } else {
+ out[o++] = *p;
+ }
+ continue;
+ }
+ if (*p == '"')
+ break;
+ out[o++] = *p;
+ }
+ return o;
+}
+
+static int extract_json_string_value(const char *payload, const char *key,
+ char *out, size_t out_sz, size_t *out_len) {
+ const char *keyp = find_json_string_key(payload, key);
+ if (!keyp)
+ return 0;
+ const char *colon = strchr(keyp + strlen(key), ':');
+ if (!colon)
+ return 0;
+ colon++;
+ while (*colon == ' ')
+ colon++;
+ if (*colon == 'n' && strncmp(colon, "null", 4) == 0)
+ return 0;
+ if (*colon != '"')
+ return 0;
+ if (colon[1] == '"')
+ return 0; /* empty */
+ size_t n = decode_json_string(colon + 1, out, out_sz);
+ if (n == 0)
+ return 0;
+ *out_len = n;
+ return 1;
+}
+
+static int json_brace_balanced(const char *s) {
+ if (!s || *s != '{')
+ return 0;
+ int depth = 0;
+ int in_str = 0;
+ for (const char *p = s; *p; p++) {
+ if (in_str) {
+ if (*p == '\\' && p[1]) {
+ p++;
+ continue;
+ }
+ if (*p == '"')
+ in_str = 0;
+ continue;
+ }
+ if (*p == '"') {
+ in_str = 1;
+ continue;
+ }
+ if (*p == '{')
+ depth++;
+ else if (*p == '}') {
+ depth--;
+ if (depth == 0)
+ return p[1] == 0;
+ if (depth < 0)
+ return 0;
+ }
+ }
+ return 0;
+}
+
+static tc_slot_t *slot_for_index(sse_acc_t *a, int index) {
+ for (int i = 0; i < TC_SLOTS; i++) {
+ if (a->slots[i].used && a->slots[i].index == index)
+ return &a->slots[i];
+ }
+ for (int i = 0; i < TC_SLOTS; i++) {
+ if (!a->slots[i].used) {
+ a->slots[i].used = 1;
+ a->slots[i].index = index;
+ a->slots[i].id[0] = 0;
+ a->slots[i].name[0] = 0;
+ a->slots[i].args = NULL;
+ a->slots[i].args_len = 0;
+ a->slots[i].args_cap = 0;
+ return &a->slots[i];
+ }
+ }
+ return NULL;
+}
+
+static int slot_append_args(tc_slot_t *s, const char *frag, size_t n) {
+ if (!s || !frag || !n)
+ return 0;
+ if (s->args_len + n + 1 > s->args_cap) {
+ size_t nc = s->args_cap ? s->args_cap * 2 : 1024;
+ while (nc < s->args_len + n + 1)
+ nc *= 2;
+ if (nc > TC_ARGS_MAX)
+ nc = TC_ARGS_MAX;
+ if (s->args_len + n + 1 > nc)
+ return -1;
+ char *nb = realloc(s->args, nc);
+ if (!nb)
+ return -1;
+ s->args = nb;
+ s->args_cap = nc;
+ }
+ memcpy(s->args + s->args_len, frag, n);
+ s->args_len += n;
+ s->args[s->args_len] = 0;
+ return 0;
+}
+
+/* Parse one tool_calls[] element object for index/id/name/arguments fragments. */
+static void parse_one_tool_call_obj(sse_acc_t *a, const char *obj) {
+ if (!obj || *obj != '{')
+ return;
+
+ int index = 0;
+ const char *ip = strstr(obj, "\"index\"");
+ if (ip) {
+ const char *colon = strchr(ip, ':');
+ if (colon) {
+ colon++;
+ while (*colon == ' ')
+ colon++;
+ index = (int)strtol(colon, NULL, 10);
+ }
+ }
+
+ tc_slot_t *slot = slot_for_index(a, index);
+ if (!slot)
+ return;
+
+ char tmp[4096];
+ size_t tlen = 0;
+ if (extract_json_string_value(obj, "\"id\"", tmp, sizeof(tmp), &tlen)) {
+ if (tlen >= TC_ID_MAX)
+ tlen = TC_ID_MAX - 1;
+ memcpy(slot->id, tmp, tlen);
+ slot->id[tlen] = 0;
+ }
+
+ /* function.name / function.arguments may be nested under "function":{...} */
+ const char *fn = strstr(obj, "\"function\"");
+ const char *scope = fn ? fn : obj;
+ tlen = 0;
+ if (extract_json_string_value(scope, "\"name\"", tmp, sizeof(tmp), &tlen)) {
+ if (tlen >= TC_NAME_MAX)
+ tlen = TC_NAME_MAX - 1;
+ memcpy(slot->name, tmp, tlen);
+ slot->name[tlen] = 0;
+ }
+ tlen = 0;
+ if (extract_json_string_value(scope, "\"arguments\"", tmp, sizeof(tmp),
+ &tlen)) {
+ slot_append_args(slot, tmp, tlen);
+ }
+}
+
+static void parse_tool_calls(sse_acc_t *a, const char *payload) {
+ const char *key = find_json_string_key(payload, "\"tool_calls\"");
+ if (!key)
+ return;
+ const char *p = strchr(key, '[');
+ if (!p)
+ return;
+ p++;
+ while (*p) {
+ while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t' || *p == ',')
+ p++;
+ if (*p == ']')
+ break;
+ if (*p != '{')
+ break;
+ /* find matching close brace for this object */
+ int depth = 0;
+ int in_str = 0;
+ const char *start = p;
+ const char *end = NULL;
+ for (; *p; p++) {
+ if (in_str) {
+ if (*p == '\\' && p[1]) {
+ p++;
+ continue;
+ }
+ if (*p == '"')
+ in_str = 0;
+ continue;
+ }
+ if (*p == '"') {
+ in_str = 1;
+ continue;
+ }
+ if (*p == '{')
+ depth++;
+ else if (*p == '}') {
+ depth--;
+ if (depth == 0) {
+ end = p;
+ p++;
+ break;
+ }
+ }
+ }
+ if (!end)
+ break;
+ size_t n = (size_t)(end - start + 1);
+ if (n >= 8192)
+ n = 8191;
+ char obj[8192];
+ memcpy(obj, start, n);
+ obj[n] = 0;
+ parse_one_tool_call_obj(a, obj);
+ }
+}
+
+static void flush_native_tools(sse_acc_t *a) {
+ if (!a->on_native_tool)
+ return;
+ for (int i = 0; i < TC_SLOTS; i++) {
+ tc_slot_t *s = &a->slots[i];
+ if (!s->used || !s->name[0] || !s->args || !s->args_len)
+ continue;
+ if (!json_brace_balanced(s->args))
+ continue;
+ const char *id = s->id[0] ? s->id : "";
+ a->on_native_tool(id, s->name, s->args, a->userdata);
+ /* Mark consumed so end-of-stream flush is idempotent after [DONE]. */
+ free(s->args);
+ s->args = NULL;
+ s->args_len = 0;
+ s->used = 0;
+ }
+}
+
+static void free_slots(sse_acc_t *a) {
+ for (int i = 0; i < TC_SLOTS; i++) {
+ free(a->slots[i].args);
+ a->slots[i].args = NULL;
+ a->slots[i].used = 0;
+ }
+}
+
+static void emit_data_payload(sse_acc_t *a, const char *payload) {
+ /* OpenAI SSE: data: {...} or data: [DONE] */
+ if (strncmp(payload, "[DONE]", 6) == 0) {
+ if (a->in_reason) {
+ emit_raw(a, " ", 8);
+ a->in_reason = 0;
+ }
+ flush_native_tools(a);
+ return;
+ }
+
+ char out[8192];
+ size_t olen = 0;
+
+ /* MiniMax reasoning_split: route reasoning_content through so the
+ * agent UI can show it, without polluting the final answer. */
+ if (extract_json_string_value(payload, "\"reasoning_content\"", out,
+ sizeof(out), &olen)) {
+ if (!a->in_reason) {
+ emit_raw(a, "", 7);
+ a->in_reason = 1;
+ }
+ emit_raw(a, out, olen);
+ }
+
+ olen = 0;
+ if (extract_json_string_value(payload, "\"content\"", out, sizeof(out),
+ &olen)) {
+ if (a->in_reason) {
+ emit_raw(a, " ", 8);
+ a->in_reason = 0;
+ }
+ emit_raw(a, out, olen);
+ }
+
+ parse_tool_calls(a, payload);
+}
+
+static void process_sse_chunk(sse_acc_t *a, const char *data, size_t n) {
+ for (size_t i = 0; i < n; i++) {
+ if (a->len + 1 >= a->cap) {
+ size_t nc = a->cap ? a->cap * 2 : 4096;
+ char *nb = realloc(a->buf, nc);
+ if (!nb)
+ return;
+ a->buf = nb;
+ a->cap = nc;
+ }
+ a->buf[a->len++] = data[i];
+ if (data[i] == '\n') {
+ a->buf[a->len] = 0;
+ /* trim CR */
+ if (a->len >= 2 && a->buf[a->len - 2] == '\r')
+ a->buf[a->len - 2] = 0;
+ else
+ a->buf[a->len - 1] = 0;
+ if (strncmp(a->buf, "data:", 5) == 0) {
+ const char *p = a->buf + 5;
+ while (*p == ' ')
+ p++;
+ emit_data_payload(a, p);
+ }
+ a->len = 0;
+ }
+ }
+}
+
+static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *userdata) {
+ sse_acc_t *a = userdata;
+ size_t n = size * nmemb;
+ process_sse_chunk(a, ptr, n);
+ return n;
+}
+
+static int xferinfo_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow,
+ curl_off_t ultotal, curl_off_t ulnow) {
+ (void)dltotal;
+ (void)dlnow;
+ (void)ultotal;
+ (void)ulnow;
+ volatile int *flag = (volatile int *)clientp;
+ if (flag && *flag != 0)
+ return 1; /* abort */
+ return 0;
+}
+
+static int json_escape_append(char **buf, size_t *len, size_t *cap,
+ const char *s) {
+ if (!buf || !len || !cap)
+ return -1;
+ if (!*buf || *cap == 0) {
+ *cap = 4096;
+ *buf = malloc(*cap);
+ if (!*buf)
+ return -1;
+ *len = 0;
+ (*buf)[0] = 0;
+ }
+ if (!s)
+ s = "";
+ for (; *s; s++) {
+ char tmp[8];
+ const char *piece = tmp;
+ size_t pn = 1;
+ unsigned char c = (unsigned char)*s;
+ if (c == '"' || c == '\\') {
+ tmp[0] = '\\';
+ tmp[1] = (char)c;
+ pn = 2;
+ } else if (c == '\n') {
+ piece = "\\n";
+ pn = 2;
+ } else if (c == '\r') {
+ piece = "\\r";
+ pn = 2;
+ } else if (c == '\t') {
+ piece = "\\t";
+ pn = 2;
+ } else if (c < 0x20) {
+ snprintf(tmp, sizeof(tmp), "\\u%04x", c);
+ pn = 6;
+ } else {
+ tmp[0] = (char)c;
+ pn = 1;
+ }
+ if (*len + pn + 1 > *cap) {
+ size_t nc = *cap ? *cap * 2 : 4096;
+ while (nc < *len + pn + 1)
+ nc *= 2;
+ char *nb = realloc(*buf, nc);
+ if (!nb)
+ return -1;
+ *buf = nb;
+ *cap = nc;
+ }
+ memcpy(*buf + *len, piece, pn);
+ *len += pn;
+ }
+ (*buf)[*len] = 0;
+ return 0;
+}
+
+static int body_ensure(char **body, size_t *blen, size_t *bcap, size_t need) {
+ if (*blen + need + 1 <= *bcap)
+ return 0;
+ size_t nc = *bcap ? *bcap * 2 : 4096;
+ while (nc < *blen + need + 1)
+ nc *= 2;
+ char *nb = realloc(*body, nc);
+ if (!nb)
+ return -1;
+ *body = nb;
+ *bcap = nc;
+ return 0;
+}
+
+static int body_append_raw(char **body, size_t *blen, size_t *bcap,
+ const char *s) {
+ size_t n = strlen(s);
+ if (body_ensure(body, blen, bcap, n) != 0)
+ return -1;
+ memcpy(*body + *blen, s, n);
+ *blen += n;
+ (*body)[*blen] = 0;
+ return 0;
+}
+
+static const char *role_api(cst_role_t r) {
+ switch (r) {
+ case CST_ROLE_SYSTEM:
+ return "system";
+ case CST_ROLE_USER:
+ return "user";
+ case CST_ROLE_ASSISTANT:
+ return "assistant";
+ case CST_ROLE_TOOL:
+ return "tool";
+ }
+ return "user";
+}
+
+static const char *TOOLS_JSON =
+ ",\"tools\":["
+ "{\"type\":\"function\",\"function\":{\"name\":\"read_file\","
+ "\"description\":\"Read a text file under the workspace\","
+ "\"parameters\":{\"type\":\"object\",\"properties\":{"
+ "\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}}},"
+ "{\"type\":\"function\",\"function\":{\"name\":\"run_shell\","
+ "\"description\":\"Run a shell command in the workspace root\","
+ "\"parameters\":{\"type\":\"object\",\"properties\":{"
+ "\"cmd\":{\"type\":\"string\"}},\"required\":[\"cmd\"]}}},"
+ "{\"type\":\"function\",\"function\":{\"name\":\"write_file\","
+ "\"description\":\"Write full file contents under the workspace\","
+ "\"parameters\":{\"type\":\"object\",\"properties\":{"
+ "\"path\":{\"type\":\"string\"},\"content\":{\"type\":\"string\"}},"
+ "\"required\":[\"path\",\"content\"]}}},"
+ "{\"type\":\"function\",\"function\":{\"name\":\"apply_patch\","
+ "\"description\":\"Apply a unified diff or Update File patch\","
+ "\"parameters\":{\"type\":\"object\",\"properties\":{"
+ "\"patch\":{\"type\":\"string\"}},\"required\":[\"patch\"]}}}"
+ "]";
+
+int cst_llm_chat_stream(const cst_llm_request_t *req, char *err, size_t err_sz) {
+ if (err && err_sz)
+ err[0] = 0;
+ if (!req || !req->api_base || !req->model) {
+ if (err)
+ snprintf(err, err_sz, "bad request");
+ return -1;
+ }
+
+ char *body = NULL;
+ size_t blen = 0;
+ size_t bcap = 8192;
+ body = malloc(bcap);
+ if (!body)
+ return -1;
+ /* MiniMax: keep think+answer in content (no reasoning_split). Split mode
+ * often leaves content empty while the useful text is only in
+ * reasoning_content, which made the agent exit with blank turns. */
+ blen = (size_t)snprintf(body, bcap,
+ "{\"model\":\"%s\",\"stream\":true,\"messages\":[",
+ req->model);
+
+ for (int i = 0; i < req->nmsgs; i++) {
+ const cst_message_t *m = &req->msgs[i];
+ char prefix[256];
+ int first = i ? 0 : 1;
+ if (m->role == CST_ROLE_ASSISTANT && m->tool_calls_json &&
+ m->tool_calls_json[0]) {
+ /* OpenCode/OpenAI: tool-only assistant uses content:null */
+ int empty =
+ !m->content || !m->content[0] ||
+ (m->content[0] == '\0');
+ if (empty) {
+ snprintf(prefix, sizeof(prefix),
+ "%s{\"role\":\"assistant\",\"content\":null,\"tool_calls\":",
+ first ? "" : ",");
+ if (body_append_raw(&body, &blen, &bcap, prefix) != 0 ||
+ body_append_raw(&body, &blen, &bcap, m->tool_calls_json) != 0 ||
+ body_append_raw(&body, &blen, &bcap, "}") != 0) {
+ free(body);
+ return -1;
+ }
+ } else {
+ snprintf(prefix, sizeof(prefix),
+ "%s{\"role\":\"assistant\",\"content\":\"", first ? "" : ",");
+ size_t pl = strlen(prefix);
+ if (blen + pl + 8 > bcap) {
+ bcap = (blen + pl) * 2 + 1024;
+ body = realloc(body, bcap);
+ if (!body)
+ return -1;
+ }
+ memcpy(body + blen, prefix, pl);
+ blen += pl;
+ body[blen] = 0;
+ if (json_escape_append(&body, &blen, &bcap, m->content) != 0) {
+ free(body);
+ return -1;
+ }
+ if (body_append_raw(&body, &blen, &bcap, "\",\"tool_calls\":") != 0 ||
+ body_append_raw(&body, &blen, &bcap, m->tool_calls_json) != 0 ||
+ body_append_raw(&body, &blen, &bcap, "}") != 0) {
+ free(body);
+ return -1;
+ }
+ }
+ continue;
+ }
+ if (m->role == CST_ROLE_TOOL && m->tool_call_id[0]) {
+ /* Include name when known (OpenAI-compatible gateways accept it). */
+ if (m->tool_name[0]) {
+ snprintf(prefix, sizeof(prefix),
+ "%s{\"role\":\"%s\",\"tool_call_id\":\"%s\",\"name\":\"%s\","
+ "\"content\":\"",
+ first ? "" : ",", role_api(m->role), m->tool_call_id,
+ m->tool_name);
+ } else {
+ snprintf(prefix, sizeof(prefix),
+ "%s{\"role\":\"%s\",\"tool_call_id\":\"%s\",\"content\":\"",
+ first ? "" : ",", role_api(m->role), m->tool_call_id);
+ }
+ } else {
+ snprintf(prefix, sizeof(prefix), "%s{\"role\":\"%s\",\"content\":\"",
+ first ? "" : ",", role_api(m->role));
+ }
+ size_t pl = strlen(prefix);
+ if (blen + pl + 8 > bcap) {
+ bcap = (blen + pl) * 2 + 1024;
+ body = realloc(body, bcap);
+ if (!body)
+ return -1;
+ }
+ memcpy(body + blen, prefix, pl);
+ blen += pl;
+ body[blen] = 0;
+ /* Keep full tool output in session/TUI; shrink only the wire payload so
+ * large write diffs don't blow MiniMax/JSON limits. */
+ {
+ const char *raw = m->content ? m->content : "";
+ char *scratch = NULL;
+ const char *send = raw;
+ enum { API_TOOL_CAP = 3500 };
+ if (m->role == CST_ROLE_TOOL && strlen(raw) > API_TOOL_CAP) {
+ scratch = malloc(API_TOOL_CAP + 80);
+ if (scratch) {
+ snprintf(scratch, API_TOOL_CAP + 80,
+ "%.*s\n…(truncated for model; full diff in UI/session)",
+ API_TOOL_CAP, raw);
+ send = scratch;
+ }
+ }
+ if (json_escape_append(&body, &blen, &bcap, send) != 0) {
+ free(scratch);
+ free(body);
+ return -1;
+ }
+ free(scratch);
+ }
+ if (blen + 3 > bcap) {
+ bcap *= 2;
+ body = realloc(body, bcap);
+ if (!body)
+ return -1;
+ }
+ body[blen++] = '"';
+ body[blen++] = '}';
+ body[blen] = 0;
+ }
+ if (body_append_raw(&body, &blen, &bcap, "]") != 0) {
+ free(body);
+ return -1;
+ }
+ if (req->enable_tools) {
+ if (body_append_raw(&body, &blen, &bcap, TOOLS_JSON) != 0) {
+ free(body);
+ return -1;
+ }
+ if (req->tool_choice_none) {
+ if (body_append_raw(&body, &blen, &bcap, ",\"tool_choice\":\"none\"") !=
+ 0) {
+ free(body);
+ return -1;
+ }
+ } else {
+ if (body_append_raw(&body, &blen, &bcap, ",\"tool_choice\":\"auto\"") !=
+ 0) {
+ free(body);
+ return -1;
+ }
+ }
+ }
+ if (body_append_raw(&body, &blen, &bcap, "}") != 0) {
+ free(body);
+ return -1;
+ }
+
+ char url[1024];
+ snprintf(url, sizeof(url), "%s/chat/completions", req->api_base);
+
+ CURL *curl = curl_easy_init();
+ if (!curl) {
+ free(body);
+ if (err)
+ snprintf(err, err_sz, "curl init failed");
+ return -1;
+ }
+ sse_acc_t acc = {0};
+ acc.on_delta = req->on_delta;
+ acc.on_native_tool = req->on_native_tool;
+ acc.userdata = req->userdata;
+
+ struct curl_slist *hdrs = NULL;
+ hdrs = curl_slist_append(hdrs, "Content-Type: application/json");
+ char auth[600];
+ if (req->api_key && req->api_key[0]) {
+ snprintf(auth, sizeof(auth), "Authorization: Bearer %s", req->api_key);
+ hdrs = curl_slist_append(hdrs, auth);
+ }
+
+ curl_easy_setopt(curl, CURLOPT_URL, url);
+ curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
+ curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, &acc);
+ curl_easy_setopt(curl, CURLOPT_TIMEOUT, 300L);
+
+ if (req->cancel_flag) {
+ curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
+ curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xferinfo_cb);
+ curl_easy_setopt(curl, CURLOPT_XFERINFODATA, (void *)req->cancel_flag);
+ }
+
+ CURLcode rc = curl_easy_perform(curl);
+ long http = 0;
+ curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http);
+ if (acc.in_reason) {
+ emit_raw(&acc, " ", 8);
+ acc.in_reason = 0;
+ }
+ /* Stream ended without [DONE] — still flush completed native tools. */
+ if (rc == CURLE_OK)
+ flush_native_tools(&acc);
+ curl_slist_free_all(hdrs);
+ curl_easy_cleanup(curl);
+
+ if (rc != CURLE_OK) {
+ if (err) {
+ if (rc == CURLE_ABORTED_BY_CALLBACK)
+ snprintf(err, err_sz, "cancelled");
+ else
+ snprintf(err, err_sz, "curl: %s", curl_easy_strerror(rc));
+ }
+ free_slots(&acc);
+ free(acc.buf);
+ free(body);
+ return -1;
+ }
+ if (http >= 400) {
+ if (err) {
+ /* show a snippet of body if present */
+ if (acc.buf && acc.len) {
+ acc.buf[acc.len < 200 ? acc.len : 200] = 0;
+ snprintf(err, err_sz, "http %ld: %.180s", http, acc.buf);
+ } else {
+ snprintf(err, err_sz, "http %ld", http);
+ }
+ }
+ free_slots(&acc);
+ free(acc.buf);
+ free(body);
+ return -1;
+ }
+ free_slots(&acc);
+ free(acc.buf);
+ free(body);
+ return 0;
+}
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..826d17b
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,283 @@
+#include "cst_agent.h"
+#include "os_compat.h"
+
+#include
+#include
+#include
+#include
+#include
+#ifdef _WIN32
+#include
+#else
+#include
+#endif
+
+static void usage(const char *argv0) {
+ fprintf(stderr,
+ "Usage: %s [--workspace DIR] [--session FILE] [--api-base URL]\n"
+ " [--api-key KEY] [--model NAME] [--headless]\n"
+ " [--prompt TEXT] [--demo-ctl] [--ask-key]\n"
+ "\n"
+ "Defaults: api-base=https://api.minimax.cn/v1 model=MiniMax-M2.7\n"
+ "Session: /.agent/sessions/.jsonl (CURRENT tracked)\n"
+ "API key: CST_API_KEY / OPENAI_API_KEY / MINIMAX_API_KEY\n"
+ "Base: CST_API_BASE / OPENAI_BASE_URL (OpenAI-compatible …/v1)\n"
+ "Model: CST_MODEL / OPENAI_MODEL\n"
+ "Also: CST_MODELS, CST_AUTO_APPROVE=1, CST_SHELL, CST_EXE_DIR\n"
+ "TUI: /new /session /model /compact /details /help\n"
+ " Enter send · Ctrl+N newline · ↑↓ history · Esc cancel\n"
+ " busy Enter queues · tool perm y/n/a=always · wheel scroll\n",
+ argv0);
+}
+
+static int g_ready;
+static ctl_tool_call_t g_call;
+
+static void demo_on_ev(const ctl_event_t *ev, void *ud) {
+ (void)ud;
+ if (ev->type == CTL_EV_TEXT)
+ fwrite(ev->text, 1, ev->text_len, stdout);
+ if (ev->type == CTL_EV_CALL_READY) {
+ snprintf(g_call.id, sizeof(g_call.id), "%s", ev->call->id);
+ snprintf(g_call.name, sizeof(g_call.name), "%s", ev->call->name);
+ for (int i = 0; i < g_call.nargs; i++) {
+ free(g_call.args[i].value);
+ g_call.args[i].value = NULL;
+ }
+ g_call.nargs = 0;
+ for (int i = 0; i < ev->call->nargs; i++) {
+ snprintf(g_call.args[i].key, sizeof(g_call.args[i].key), "%s",
+ ev->call->args[i].key);
+ g_call.args[i].value_len = ev->call->args[i].value_len;
+ g_call.args[i].value = malloc(ev->call->args[i].value_len + 1);
+ memcpy(g_call.args[i].value, ev->call->args[i].value,
+ ev->call->args[i].value_len);
+ g_call.args[i].value[ev->call->args[i].value_len] = 0;
+ g_call.nargs++;
+ }
+ g_ready = 1;
+ }
+}
+
+static int demo_ctl_impl(const char *workspace) {
+ printf("=== demo CTL (byte-at-a-time feed) ===\n");
+ g_ready = 0;
+ memset(&g_call, 0, sizeof(g_call));
+ ctl_parser_t *p = ctl_parser_create(demo_on_ev, NULL);
+ const char *stream =
+ "Reading README.\n"
+ "call t1 read_file\n"
+ "arg path 9\n"
+ "README.md"
+ "end\n";
+ for (size_t i = 0; stream[i]; i++)
+ ctl_parser_feed(p, &stream[i], 1);
+ if (ctl_parser_finish(p) != CTL_OK || !g_ready) {
+ fprintf(stderr, "demo: CTL failed\n");
+ ctl_parser_destroy(p);
+ return 1;
+ }
+ printf("\npending_peak=%zu\n", ctl_parser_pending_peak(p));
+ ctl_parser_destroy(p);
+
+ cst_tool_result_t r = {0};
+ cst_tool_dispatch(workspace, &g_call, &r);
+ printf("[tool result exit=%d]\n%.*s\n", r.exit_code, (int)r.out_len,
+ r.out ? r.out : "");
+ cst_tool_result_free(&r);
+ for (int i = 0; i < g_call.nargs; i++)
+ free(g_call.args[i].value);
+ return 0;
+}
+
+int main(int argc, char **argv) {
+ os_set_prog_path(argv[0]);
+ curl_global_init(CURL_GLOBAL_DEFAULT);
+
+ cst_agent_config_t cfg = {0};
+ char cwd[4096];
+#ifdef _WIN32
+ if (!_getcwd(cwd, (int)sizeof(cwd)))
+#else
+ if (!getcwd(cwd, sizeof(cwd)))
+#endif
+ snprintf(cwd, sizeof(cwd), ".");
+ snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", cwd);
+ cfg.session_path[0] = 0; /* filled after workspace opts via .agent/store */
+ /* OpenAI-compatible base URL aliases */
+ const char *env_base = getenv("CST_API_BASE");
+ if (!env_base || !env_base[0])
+ env_base = getenv("OPENAI_BASE_URL");
+ if (!env_base || !env_base[0])
+ env_base = getenv("OPENAI_API_BASE");
+ const char *env_model = getenv("CST_MODEL");
+ if (!env_model || !env_model[0])
+ env_model = getenv("OPENAI_MODEL");
+ snprintf(cfg.api_base, sizeof(cfg.api_base), "%s",
+ env_base && env_base[0] ? env_base : "https://api.minimax.cn/v1");
+ cfg.api_key[0] = 0;
+ snprintf(cfg.model, sizeof(cfg.model), "%s",
+ env_model && env_model[0] ? env_model : "MiniMax-M2.7");
+ cfg.headless = 0;
+ cfg.confirm_shell = 1;
+ cfg.confirm_write = 1;
+ cfg.auto_approve = 0;
+ if (getenv("CST_AUTO_APPROVE"))
+ cfg.auto_approve = 1;
+
+ int do_demo = 0;
+ int force_ask_key = 0;
+ int session_explicit = 0;
+ const char *prompt = NULL;
+ int key_from_cli = 0;
+
+ static struct option opts[] = {
+ {"workspace", required_argument, 0, 'w'},
+ {"session", required_argument, 0, 's'},
+ {"api-base", required_argument, 0, 'b'},
+ {"api-key", required_argument, 0, 'k'},
+ {"model", required_argument, 0, 'm'},
+ {"headless", no_argument, 0, 'H'},
+ {"prompt", required_argument, 0, 'p'},
+ {"demo-ctl", no_argument, 0, 'd'},
+ {"ask-key", no_argument, 0, 'a'},
+ {"help", no_argument, 0, 'h'},
+ {0, 0, 0, 0}};
+ int c;
+ while ((c = getopt_long(argc, argv, "w:s:b:k:m:Hp:dah", opts, NULL)) != -1) {
+ switch (c) {
+ case 'w':
+ snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", optarg);
+ break;
+ case 's':
+ snprintf(cfg.session_path, sizeof(cfg.session_path), "%s", optarg);
+ session_explicit = 1;
+ break;
+ case 'b':
+ snprintf(cfg.api_base, sizeof(cfg.api_base), "%s", optarg);
+ break;
+ case 'k':
+ snprintf(cfg.api_key, sizeof(cfg.api_key), "%s", optarg);
+ key_from_cli = 1;
+ break;
+ case 'm':
+ snprintf(cfg.model, sizeof(cfg.model), "%s", optarg);
+ break;
+ case 'H':
+ cfg.headless = 1;
+ break;
+ case 'p':
+ prompt = optarg;
+ break;
+ case 'd':
+ do_demo = 1;
+ break;
+ case 'a':
+ force_ask_key = 1;
+ break;
+ case 'h':
+ usage(argv[0]);
+ curl_global_cleanup();
+ return 0;
+ default:
+ usage(argv[0]);
+ curl_global_cleanup();
+ return 1;
+ }
+ }
+
+ /* trim trailing slash on api_base */
+ size_t alen = strlen(cfg.api_base);
+ while (alen > 0 && cfg.api_base[alen - 1] == '/')
+ cfg.api_base[--alen] = 0;
+
+ /* Headless defaults to auto-approve shell/write (no interactive confirm). */
+ if (cfg.headless)
+ cfg.auto_approve = 1;
+
+ /* Default session under /.agent/sessions/ (OpenCode-like). */
+ if (!session_explicit) {
+ char id[128];
+ if (cst_agent_store_resolve_default(cfg.workspace_root, cfg.session_path,
+ sizeof(cfg.session_path), id,
+ sizeof(id)) != 0) {
+ fprintf(stderr, "failed to init .agent/sessions under %s\n",
+ cfg.workspace_root);
+ curl_global_cleanup();
+ return 1;
+ }
+ fprintf(stderr, "session %s\n", id);
+ }
+
+ if (do_demo) {
+ int rc = demo_ctl_impl(cfg.workspace_root);
+ curl_global_cleanup();
+ return rc;
+ }
+
+ if (force_ask_key || !key_from_cli) {
+ if (force_ask_key) {
+ if (cst_creds_prompt_api_key(cfg.api_key, sizeof(cfg.api_key)) != 0) {
+ fprintf(stderr, "API key prompt cancelled\n");
+ curl_global_cleanup();
+ return 1;
+ }
+ if (cst_creds_save_api_key(cfg.api_key) != 0)
+ fprintf(stderr, "warning: failed to save credentials\n");
+ else
+ fprintf(stderr, "saved API key to ~/.config/cst-agent/credentials\n");
+ /* --ask-key alone: save and exit unless running a turn/TUI */
+ if (!cfg.headless && !prompt) {
+ fprintf(stderr, "using %s model=%s\n", cfg.api_base, cfg.model);
+ curl_global_cleanup();
+ return 0;
+ }
+ } else if (!cfg.api_key[0]) {
+ if (cst_creds_ensure_api_key(cfg.api_key, sizeof(cfg.api_key)) != 0) {
+ fprintf(stderr, "no API key (set CST_API_KEY or use --ask-key)\n");
+ curl_global_cleanup();
+ return 1;
+ }
+ }
+ } else if (key_from_cli) {
+ /* persist CLI-provided key so next launch can reuse */
+ if (cst_creds_save_api_key(cfg.api_key) != 0)
+ fprintf(stderr, "warning: failed to save credentials\n");
+ }
+
+ fprintf(stderr, "using %s model=%s\n", cfg.api_base, cfg.model);
+
+ cst_agent_t *agent = cst_agent_create(&cfg);
+ if (!agent) {
+ fprintf(stderr, "failed to create agent (session path?)\n");
+ curl_global_cleanup();
+ return 1;
+ }
+
+ int rc = 0;
+ if (cfg.headless) {
+ if (!prompt) {
+ fprintf(stderr, "--headless requires --prompt\n");
+ rc = 1;
+ } else {
+ char err[512];
+ if (cst_agent_run_turn(agent, prompt, err, sizeof(err)) != 0) {
+ fprintf(stderr, "turn failed: %s\n", err);
+ rc = 1;
+ } else {
+ cst_session_t *s = cst_agent_session(agent);
+ for (int i = 0; i < cst_session_count(s); i++) {
+ const cst_message_t *m = cst_session_get(s, i);
+ printf("[%d role=%d] %s\n", i, (int)m->role,
+ m->content ? m->content : "");
+ }
+ }
+ }
+ } else {
+ rc = cst_tui_run(agent);
+ }
+
+ cst_agent_destroy(agent);
+ curl_global_cleanup();
+ return rc;
+}
diff --git a/src/mm_tool.c b/src/mm_tool.c
new file mode 100644
index 0000000..e9af8db
--- /dev/null
+++ b/src/mm_tool.c
@@ -0,0 +1,410 @@
+#include "mm_tool.h"
+
+#include
+#include
+#include
+
+#define MM_OPEN ""
+#define MM_CLOSE " "
+#define MM_OPEN_LEN 19
+#define MM_CLOSE_LEN 20
+
+struct mm_scan {
+ mm_on_event_fn on_event;
+ void *userdata;
+ char *buf;
+ size_t len;
+ size_t cap;
+ size_t pending_peak;
+ int in_tool; /* 1 if last open is unmatched */
+ char idbuf[CTL_MAX_ID];
+ unsigned id_seq;
+};
+
+static void emit(mm_scan_t *s, ctl_event_t *ev) {
+ if (s->on_event)
+ s->on_event(ev, s->userdata);
+}
+
+static void emit_text(mm_scan_t *s, const char *t, size_t n) {
+ if (!t || !n)
+ return;
+ ctl_event_t ev = {0};
+ ev.type = CTL_EV_TEXT;
+ ev.text = t;
+ ev.text_len = n;
+ emit(s, &ev);
+}
+
+static int ensure(mm_scan_t *s, size_t need) {
+ if (s->len + need + 1 <= s->cap)
+ return 0;
+ size_t nc = s->cap ? s->cap * 2 : 4096;
+ while (nc < s->len + need + 1)
+ nc *= 2;
+ char *nb = realloc(s->buf, nc);
+ if (!nb)
+ return -1;
+ s->buf = nb;
+ s->cap = nc;
+ return 0;
+}
+
+static void bump_pending(mm_scan_t *s) {
+ if (s->len > s->pending_peak)
+ s->pending_peak = s->len;
+}
+
+mm_scan_t *mm_scan_create(mm_on_event_fn on_event, void *userdata) {
+ mm_scan_t *s = calloc(1, sizeof(*s));
+ if (!s)
+ return NULL;
+ s->on_event = on_event;
+ s->userdata = userdata;
+ s->id_seq = 1;
+ return s;
+}
+
+void mm_scan_destroy(mm_scan_t *s) {
+ if (!s)
+ return;
+ free(s->buf);
+ free(s);
+}
+
+void mm_scan_reset(mm_scan_t *s) {
+ if (!s)
+ return;
+ s->len = 0;
+ s->in_tool = 0;
+ if (s->buf)
+ s->buf[0] = 0;
+}
+
+size_t mm_scan_pending(const mm_scan_t *s) {
+ return s ? (s->in_tool ? s->len : 0) : 0;
+}
+
+static void clear_call(ctl_tool_call_t *c) {
+ for (int i = 0; i < c->nargs; i++) {
+ free(c->args[i].value);
+ c->args[i].value = NULL;
+ }
+ memset(c, 0, sizeof(*c));
+}
+
+static int add_arg(ctl_tool_call_t *c, const char *key, const char *val,
+ size_t vlen) {
+ if (c->nargs >= CTL_MAX_ARGS)
+ return -1;
+ if (strlen(key) >= CTL_MAX_KEY)
+ return -1;
+ snprintf(c->args[c->nargs].key, CTL_MAX_KEY, "%s", key);
+ c->args[c->nargs].value = malloc(vlen + 1);
+ if (!c->args[c->nargs].value)
+ return -1;
+ memcpy(c->args[c->nargs].value, val, vlen);
+ c->args[c->nargs].value[vlen] = 0;
+ c->args[c->nargs].value_len = vlen;
+ c->nargs++;
+ return 0;
+}
+
+/* Extract name="..." or name='...' from a tag start. */
+static int extract_name_attr(const char *tag, char *out, size_t out_sz) {
+ const char *p = strstr(tag, "name=");
+ if (!p)
+ return -1;
+ p += 5;
+ char quote = 0;
+ if (*p == '"' || *p == '\'') {
+ quote = *p++;
+ }
+ size_t i = 0;
+ while (*p && i + 1 < out_sz) {
+ if (quote) {
+ if (*p == quote)
+ break;
+ } else {
+ if (*p == ' ' || *p == '>' || *p == '/')
+ break;
+ }
+ out[i++] = *p++;
+ }
+ out[i] = 0;
+ return i > 0 ? 0 : -1;
+}
+
+static void emit_call(mm_scan_t *s, ctl_tool_call_t *call) {
+ /* Only emit allowlisted tools — prevents "cmd" etc. from becoming tools. */
+ if (!call->name[0])
+ return;
+ if (strcmp(call->name, "read_file") != 0 &&
+ strcmp(call->name, "run_shell") != 0 &&
+ strcmp(call->name, "write_file") != 0 &&
+ strcmp(call->name, "apply_patch") != 0)
+ return;
+ ctl_event_t ev = {0};
+ ev.type = CTL_EV_CALL_OPEN;
+ ev.call = call;
+ emit(s, &ev);
+ ev.type = CTL_EV_CALL_READY;
+ ev.call = call;
+ emit(s, &ev);
+}
+
+/*
+ * Parse one complete tool_call inner body (between open/close tags).
+ * Emits one CALL_READY per invoke.
+ */
+static void parse_tool_block(mm_scan_t *s, const char *inner, size_t n) {
+ char *block = malloc(n + 1);
+ if (!block)
+ return;
+ memcpy(block, inner, n);
+ block[n] = 0;
+
+ /* Normalize: support both and bare "invoke x" lines. */
+ char *p = block;
+ while (*p) {
+ /* Skip whitespace */
+ while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t')
+ p++;
+ if (!*p)
+ break;
+
+ ctl_tool_call_t call;
+ memset(&call, 0, sizeof(call));
+ snprintf(call.id, sizeof(call.id), "mm%u", s->id_seq++);
+
+ if (strncmp(p, "');
+ if (!gt) {
+ break;
+ }
+ char tag[256];
+ size_t tlen = (size_t)(gt - p) + 1;
+ if (tlen >= sizeof(tag))
+ tlen = sizeof(tag) - 1;
+ memcpy(tag, p, tlen);
+ tag[tlen] = 0;
+ if (extract_name_attr(tag, call.name, sizeof(call.name)) != 0) {
+ p = gt + 1;
+ continue;
+ }
+ p = gt + 1;
+ char *end_inv = strstr(p, " ");
+ if (!end_inv) {
+ /* unclosed invoke — treat rest as parameters until end */
+ end_inv = block + n;
+ }
+ /* parameters / args inside */
+ char *q = p;
+ while (q < end_inv) {
+ char *arg = strstr(q, "');
+ if (!agt || agt >= end_inv)
+ break;
+ char atag[256];
+ size_t al = (size_t)(agt - use) + 1;
+ if (al >= sizeof(atag))
+ al = sizeof(atag) - 1;
+ memcpy(atag, use, al);
+ atag[al] = 0;
+ char key[CTL_MAX_KEY];
+ if (extract_name_attr(atag, key, sizeof(key)) != 0) {
+ q = agt + 1;
+ continue;
+ }
+ const char *close = is_param ? " " : "";
+ char *aend = strstr(agt + 1, close);
+ if (!aend || aend > end_inv) {
+ q = agt + 1;
+ continue;
+ }
+ size_t vlen = (size_t)(aend - (agt + 1));
+ add_arg(&call, key, agt + 1, vlen);
+ q = aend + strlen(close);
+ }
+ emit_call(s, &call);
+ clear_call(&call);
+ if (end_inv < block + n && strncmp(end_inv, " ", 9) == 0)
+ p = end_inv + 9;
+ else
+ p = end_inv;
+ continue;
+ }
+
+ /* Bare: invoke NAME\n */
+ if (strncmp(p, "invoke ", 7) == 0) {
+ p += 7;
+ while (*p == ' ' || *p == '\t')
+ p++;
+ size_t ni = 0;
+ while (p[ni] && p[ni] != '\n' && p[ni] != '\r' && p[ni] != ' ' &&
+ p[ni] != '<' && ni + 1 < sizeof(call.name)) {
+ call.name[ni] = p[ni];
+ ni++;
+ }
+ call.name[ni] = 0;
+ p += ni;
+ while (*p == '\n' || *p == '\r' || *p == ' ' || *p == '\t')
+ p++;
+
+ /* Collect ... until next invoke or end */
+ while (*p) {
+ while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t')
+ p++;
+ if (strncmp(p, "invoke ", 7) == 0 || strncmp(p, "');
+ if (!agt)
+ break;
+ char atag[256];
+ size_t al = (size_t)(agt - p) + 1;
+ if (al >= sizeof(atag))
+ al = sizeof(atag) - 1;
+ memcpy(atag, p, al);
+ atag[al] = 0;
+ char key[CTL_MAX_KEY];
+ if (extract_name_attr(atag, key, sizeof(key)) != 0) {
+ p = agt + 1;
+ continue;
+ }
+ const char *close = is_param ? "" : "";
+ char *aend = strstr(agt + 1, close);
+ if (!aend)
+ break;
+ size_t vlen = (size_t)(aend - (agt + 1));
+ add_arg(&call, key, agt + 1, vlen);
+ p = aend + strlen(close);
+ }
+ if (call.name[0]) {
+ emit_call(s, &call);
+ }
+ clear_call(&call);
+ continue;
+ }
+
+ /* Unknown content — skip one char */
+ p++;
+ }
+ free(block);
+}
+
+/* Drop stray close tags that MiniMax sometimes emits outside a tool block. */
+static void strip_stray_closes(mm_scan_t *s) {
+ for (;;) {
+ char *c = strstr(s->buf, MM_CLOSE);
+ if (!c)
+ return;
+ /* Only strip if it appears before any open (orphan). */
+ char *o = strstr(s->buf, MM_OPEN);
+ if (o && o < c)
+ return;
+ size_t at = (size_t)(c - s->buf);
+ size_t rest = s->len - at - MM_CLOSE_LEN;
+ if (at > 0)
+ emit_text(s, s->buf, at);
+ memmove(s->buf, c + MM_CLOSE_LEN, rest);
+ s->len = rest;
+ s->buf[s->len] = 0;
+ }
+}
+
+/* Process buffer: emit text before tools, parse complete tool blocks. */
+static void drain(mm_scan_t *s) {
+ for (;;) {
+ if (!s->in_tool) {
+ strip_stray_closes(s);
+ char *open = strstr(s->buf, MM_OPEN);
+ if (!open) {
+ /* Keep a short tail that could be a partial open tag. */
+ size_t keep = MM_OPEN_LEN - 1;
+ if (s->len <= keep)
+ return;
+ size_t emit_n = s->len - keep;
+ /* Don't split a UTF-8 codepoint across emit/keep. */
+ while (emit_n > 0 &&
+ ((unsigned char)s->buf[emit_n] & 0xC0) == 0x80)
+ emit_n--;
+ if (emit_n == 0)
+ return;
+ emit_text(s, s->buf, emit_n);
+ memmove(s->buf, s->buf + emit_n, s->len - emit_n);
+ s->len -= emit_n;
+ s->buf[s->len] = 0;
+ return;
+ }
+ size_t before = (size_t)(open - s->buf);
+ if (before)
+ emit_text(s, s->buf, before);
+ /* drop open tag from buffer, enter tool mode */
+ size_t rest = s->len - before - MM_OPEN_LEN;
+ memmove(s->buf, open + MM_OPEN_LEN, rest);
+ s->len = rest;
+ s->buf[s->len] = 0;
+ s->in_tool = 1;
+ bump_pending(s);
+ continue;
+ }
+
+ /* in tool: wait for close */
+ char *close = strstr(s->buf, MM_CLOSE);
+ if (!close)
+ return; /* pending */
+ size_t inner_len = (size_t)(close - s->buf);
+ parse_tool_block(s, s->buf, inner_len);
+ size_t rest = s->len - inner_len - MM_CLOSE_LEN;
+ memmove(s->buf, close + MM_CLOSE_LEN, rest);
+ s->len = rest;
+ s->buf[s->len] = 0;
+ s->in_tool = 0;
+ }
+}
+
+int mm_scan_feed(mm_scan_t *s, const char *data, size_t len) {
+ if (!s || !data)
+ return -1;
+ if (ensure(s, len) != 0)
+ return -1;
+ memcpy(s->buf + s->len, data, len);
+ s->len += len;
+ s->buf[s->len] = 0;
+ bump_pending(s);
+ drain(s);
+ return 0;
+}
+
+int mm_scan_finish(mm_scan_t *s) {
+ if (!s)
+ return -1;
+ drain(s);
+ if (s->in_tool) {
+ /* Incomplete tool — do NOT execute; emit as visible text so UI isn't stuck */
+ emit_text(s, MM_OPEN, MM_OPEN_LEN);
+ if (s->len)
+ emit_text(s, s->buf, s->len);
+ s->len = 0;
+ s->in_tool = 0;
+ } else if (s->len) {
+ emit_text(s, s->buf, s->len);
+ s->len = 0;
+ }
+ return 0;
+}
diff --git a/src/models.c b/src/models.c
new file mode 100644
index 0000000..bd0b482
--- /dev/null
+++ b/src/models.c
@@ -0,0 +1,67 @@
+#include "cst_agent.h"
+
+#include
+#include
+#include
+
+#define MODEL_MAX_LEN 128
+
+static int already(char out[][MODEL_MAX_LEN], int n, const char *name) {
+ for (int i = 0; i < n; i++) {
+ if (strcmp(out[i], name) == 0)
+ return 1;
+ }
+ return 0;
+}
+
+static int push(char out[][MODEL_MAX_LEN], int *n, int max, const char *name) {
+ if (!name || !name[0] || *n >= max)
+ return 0;
+ if (already(out, *n, name))
+ return 0;
+ snprintf(out[*n], MODEL_MAX_LEN, "%s", name);
+ (*n)++;
+ return 1;
+}
+
+int cst_models_list(const char *current, char out[][128], int max_out) {
+ if (!out || max_out <= 0)
+ return 0;
+ int n = 0;
+
+ const char *env = getenv("CST_MODELS");
+ if (env && env[0]) {
+ char buf[1024];
+ snprintf(buf, sizeof(buf), "%s", env);
+ char *p = buf;
+ while (*p && n < max_out) {
+ while (*p == ':' || *p == ' ' || *p == ',')
+ p++;
+ if (!*p)
+ break;
+ char *start = p;
+ while (*p && *p != ':' && *p != ',')
+ p++;
+ char save = *p;
+ *p = 0;
+ /* trim trailing spaces */
+ char *end = start + strlen(start);
+ while (end > start && (end[-1] == ' ' || end[-1] == '\t'))
+ *--end = 0;
+ push(out, &n, max_out, start);
+ *p = save;
+ if (*p)
+ p++;
+ }
+ } else {
+ push(out, &n, max_out, "MiniMax-M2.7");
+ push(out, &n, max_out, "MiniMax-M2.5");
+ push(out, &n, max_out, "gpt-4o");
+ push(out, &n, max_out, "gpt-4o-mini");
+ }
+
+ if (current && current[0])
+ push(out, &n, max_out, current);
+
+ return n;
+}
diff --git a/src/os_compat.c b/src/os_compat.c
new file mode 100644
index 0000000..07bcdd0
--- /dev/null
+++ b/src/os_compat.c
@@ -0,0 +1,584 @@
+#define _DEFAULT_SOURCE
+#ifndef _GNU_SOURCE
+#define _GNU_SOURCE
+#endif
+
+#include "os_compat.h"
+
+#include
+#include
+#include
+#include
+
+#ifdef _WIN32
+#ifndef WIN32_LEAN_AND_MEAN
+#define WIN32_LEAN_AND_MEAN
+#endif
+#include
+#include
+#include
+#include
+#include
+#else
+#include
+#include
+#include
+#include
+#include
+#endif
+
+void os_run_result_free(os_run_result_t *r) {
+ if (!r)
+ return;
+ free(r->out);
+ r->out = NULL;
+ r->out_len = 0;
+}
+
+int os_path_is_abs(const char *p) {
+ if (!p || !p[0])
+ return 0;
+#ifdef _WIN32
+ if (p[0] == '/' || p[0] == '\\')
+ return 1;
+ if (((p[0] >= 'A' && p[0] <= 'Z') || (p[0] >= 'a' && p[0] <= 'z')) &&
+ p[1] == ':' && (p[2] == '/' || p[2] == '\\' || p[2] == '\0'))
+ return 1;
+ return 0;
+#else
+ return p[0] == '/';
+#endif
+}
+
+void os_path_norm_slashes(char *p) {
+ if (!p)
+ return;
+ for (; *p; p++) {
+ if (*p == '\\')
+ *p = '/';
+ }
+}
+
+int os_path_join(char *out, size_t out_sz, const char *a, const char *b) {
+ if (!out || out_sz < 2 || !a)
+ return -1;
+ if (!b || !b[0]) {
+ snprintf(out, out_sz, "%s", a);
+ return 0;
+ }
+ if (os_path_is_abs(b)) {
+ snprintf(out, out_sz, "%s", b);
+ return 0;
+ }
+ size_t al = strlen(a);
+ while (al > 0 && (a[al - 1] == '/' || a[al - 1] == '\\'))
+ al--;
+#ifdef _WIN32
+ int n = snprintf(out, out_sz, "%.*s\\%s", (int)al, a, b);
+#else
+ int n = snprintf(out, out_sz, "%.*s/%s", (int)al, a, b);
+#endif
+ if (n < 0 || (size_t)n >= out_sz)
+ return -1;
+ return 0;
+}
+
+int os_realpath(const char *path, char *out, size_t out_sz) {
+ if (!path || !out || out_sz < 2)
+ return -1;
+#ifdef _WIN32
+ char buf[OS_PATH_MAX];
+ DWORD n = GetFullPathNameA(path, (DWORD)sizeof(buf), buf, NULL);
+ if (n == 0 || n >= sizeof(buf))
+ return -1;
+ /* Prefer forward slashes internally for sandbox compares. */
+ os_path_norm_slashes(buf);
+ if (strlen(buf) + 1 > out_sz)
+ return -1;
+ memcpy(out, buf, strlen(buf) + 1);
+ /* Lowercase drive letter for stable compares */
+ if (out[0] && out[1] == ':') {
+ if (out[0] >= 'A' && out[0] <= 'Z')
+ out[0] = (char)(out[0] - 'A' + 'a');
+ }
+ return 0;
+#else
+ char *r = realpath(path, NULL);
+ if (!r)
+ return -1;
+ if (strlen(r) + 1 > out_sz) {
+ free(r);
+ return -1;
+ }
+ memcpy(out, r, strlen(r) + 1);
+ free(r);
+ return 0;
+#endif
+}
+
+int os_path_under(const char *root, const char *path) {
+ if (!root || !path)
+ return 0;
+ size_t rl = strlen(root);
+ if (strncmp(path, root, rl) != 0)
+ return 0;
+ if (path[rl] == '\0')
+ return 1;
+#ifdef _WIN32
+ return path[rl] == '/' || path[rl] == '\\';
+#else
+ return path[rl] == '/';
+#endif
+}
+
+int os_mkdir(const char *path, int mode) {
+ if (!path)
+ return -1;
+#ifdef _WIN32
+ (void)mode;
+ if (_mkdir(path) != 0 && errno != EEXIST)
+ return -1;
+ return 0;
+#else
+ if (mkdir(path, (mode_t)mode) != 0 && errno != EEXIST)
+ return -1;
+ return 0;
+#endif
+}
+
+int os_config_dir(char *out, size_t out_sz) {
+ if (!out || out_sz < 8)
+ return -1;
+#ifdef _WIN32
+ const char *base = getenv("APPDATA");
+ if (!base || !base[0])
+ base = getenv("LOCALAPPDATA");
+ if (!base || !base[0])
+ return -1;
+ int n = snprintf(out, out_sz, "%s\\cst-agent", base);
+#else
+ const char *home = getenv("HOME");
+ if (!home || !home[0])
+ return -1;
+ int n = snprintf(out, out_sz, "%s/.config/cst-agent", home);
+#endif
+ if (n < 0 || (size_t)n >= out_sz)
+ return -1;
+ return 0;
+}
+
+int os_temp_dir(char *out, size_t out_sz) {
+ if (!out || out_sz < 2)
+ return -1;
+#ifdef _WIN32
+ DWORD n = GetTempPathA((DWORD)out_sz, out);
+ if (n == 0 || n >= out_sz)
+ return -1;
+ os_path_norm_slashes(out);
+ size_t L = strlen(out);
+ while (L > 0 && out[L - 1] == '/')
+ out[--L] = 0;
+ return 0;
+#else
+ const char *t = getenv("TMPDIR");
+ if (!t || !t[0])
+ t = "/tmp";
+ snprintf(out, out_sz, "%s", t);
+ return 0;
+#endif
+}
+
+int os_mkstemp(char *out, size_t out_sz, const char *prefix) {
+ if (!out || out_sz < 16)
+ return -1;
+ char dir[OS_PATH_MAX];
+ if (os_temp_dir(dir, sizeof(dir)) != 0)
+ return -1;
+ if (!prefix)
+ prefix = "cst";
+#ifdef _WIN32
+ char name[MAX_PATH];
+ if (GetTempFileNameA(dir, prefix, 0, name) == 0)
+ return -1;
+ os_path_norm_slashes(name);
+ if (strlen(name) + 1 > out_sz)
+ return -1;
+ memcpy(out, name, strlen(name) + 1);
+ int fd = _open(out, _O_RDWR | _O_BINARY);
+ return fd;
+#else
+ int n = snprintf(out, out_sz, "%s/%s-XXXXXX", dir, prefix);
+ if (n < 0 || (size_t)n >= out_sz)
+ return -1;
+ int fd = mkstemp(out);
+ return fd;
+#endif
+}
+
+/* Optional: main() may set this for Termux / argv0 fallback when
+ * /proc/self/exe is unavailable. */
+static char g_argv0_dir[OS_PATH_MAX];
+
+void os_set_prog_path(const char *argv0) {
+ g_argv0_dir[0] = 0;
+ if (!argv0 || !argv0[0])
+ return;
+ char buf[OS_PATH_MAX];
+ snprintf(buf, sizeof(buf), "%s", argv0);
+ os_path_norm_slashes(buf);
+ char *slash = strrchr(buf, '/');
+ if (!slash)
+ return;
+ *slash = 0;
+ /* Relative "./foo" → keep as-is; caller may pass absolute. */
+ snprintf(g_argv0_dir, sizeof(g_argv0_dir), "%s", buf);
+}
+
+int os_exe_dir(char *out, size_t out_sz) {
+ if (!out || out_sz < 2)
+ return -1;
+ const char *env = getenv("CST_EXE_DIR");
+ if (env && env[0]) {
+ snprintf(out, out_sz, "%s", env);
+ return 0;
+ }
+#ifdef _WIN32
+ char buf[OS_PATH_MAX];
+ DWORD n = GetModuleFileNameA(NULL, buf, (DWORD)sizeof(buf));
+ if (n == 0 || n >= sizeof(buf))
+ return -1;
+ os_path_norm_slashes(buf);
+ char *slash = strrchr(buf, '/');
+ if (!slash)
+ return -1;
+ *slash = 0;
+ if (strlen(buf) + 1 > out_sz)
+ return -1;
+ memcpy(out, buf, strlen(buf) + 1);
+ return 0;
+#else
+ char buf[OS_PATH_MAX];
+ ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1);
+ if (n >= 0) {
+ buf[n] = 0;
+ char *slash = strrchr(buf, '/');
+ if (slash) {
+ *slash = 0;
+ if (strlen(buf) + 1 <= out_sz) {
+ memcpy(out, buf, strlen(buf) + 1);
+ return 0;
+ }
+ }
+ }
+ /* Termux / restricted Android: fall back to argv0 dir. */
+ if (g_argv0_dir[0]) {
+ snprintf(out, out_sz, "%s", g_argv0_dir);
+ return 0;
+ }
+ return -1;
+#endif
+}
+
+static int file_exists(const char *p) {
+#ifdef _WIN32
+ DWORD a = GetFileAttributesA(p);
+ return a != INVALID_FILE_ATTRIBUTES && !(a & FILE_ATTRIBUTE_DIRECTORY);
+#else
+ return access(p, X_OK) == 0 || access(p, F_OK) == 0;
+#endif
+}
+
+int os_resolve_shell(char *out, size_t out_sz) {
+ if (!out || out_sz < 2)
+ return -1;
+ const char *env = getenv("CST_SHELL");
+ if (env && env[0]) {
+ snprintf(out, out_sz, "%s", env);
+ return 0;
+ }
+ char dir[OS_PATH_MAX];
+ if (os_exe_dir(dir, sizeof(dir)) == 0) {
+#ifdef _WIN32
+ char cand[OS_PATH_MAX];
+ if (os_path_join(cand, sizeof(cand), dir, "busybox.exe") == 0 &&
+ file_exists(cand)) {
+ snprintf(out, out_sz, "%s", cand);
+ return 0;
+ }
+#else
+ char cand[OS_PATH_MAX];
+ if (os_path_join(cand, sizeof(cand), dir, "busybox") == 0 &&
+ file_exists(cand)) {
+ snprintf(out, out_sz, "%s", cand);
+ return 0;
+ }
+#endif
+ }
+#ifdef _WIN32
+ snprintf(out, out_sz, "%s", "busybox.exe");
+ return 0;
+#else
+ /* Termux: $PREFIX/bin/sh */
+ {
+ const char *prefix = getenv("PREFIX");
+ if (prefix && prefix[0]) {
+ char cand[OS_PATH_MAX];
+ if (os_path_join(cand, sizeof(cand), prefix, "bin/sh") == 0 &&
+ file_exists(cand)) {
+ snprintf(out, out_sz, "%s", cand);
+ return 0;
+ }
+ }
+ }
+ if (file_exists("/bin/sh")) {
+ snprintf(out, out_sz, "%s", "/bin/sh");
+ return 0;
+ }
+ if (file_exists("/system/bin/sh")) {
+ snprintf(out, out_sz, "%s", "/system/bin/sh");
+ return 0;
+ }
+ snprintf(out, out_sz, "%s", "sh");
+ return 0;
+#endif
+}
+
+#ifdef _WIN32
+int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
+ os_run_result_t *r) {
+ if (!argv || !argv[0] || !r)
+ return -1;
+ if (max_out == 0)
+ max_out = 512 * 1024;
+ memset(r, 0, sizeof(*r));
+
+ char cmdline[8192];
+ size_t pos = 0;
+ cmdline[0] = 0;
+ for (int i = 0; argv[i]; i++) {
+ const char *a = argv[i];
+ int need_q = strchr(a, ' ') || strchr(a, '\t') || strchr(a, '"');
+ if (i && pos + 1 < sizeof(cmdline))
+ cmdline[pos++] = ' ';
+ if (need_q) {
+ if (pos + 1 < sizeof(cmdline))
+ cmdline[pos++] = '"';
+ for (const char *p = a; *p && pos + 2 < sizeof(cmdline); p++) {
+ if (*p == '"') {
+ cmdline[pos++] = '\\';
+ cmdline[pos++] = '"';
+ } else {
+ cmdline[pos++] = *p;
+ }
+ }
+ if (pos + 1 < sizeof(cmdline))
+ cmdline[pos++] = '"';
+ } else {
+ size_t L = strlen(a);
+ if (pos + L >= sizeof(cmdline))
+ break;
+ memcpy(cmdline + pos, a, L);
+ pos += L;
+ }
+ cmdline[pos] = 0;
+ }
+
+ SECURITY_ATTRIBUTES sa = {0};
+ sa.nLength = sizeof(sa);
+ sa.bInheritHandle = TRUE;
+ HANDLE rd = NULL, wr = NULL;
+ if (!CreatePipe(&rd, &wr, &sa, 0))
+ return -1;
+ SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0);
+
+ STARTUPINFOA si = {0};
+ si.cb = sizeof(si);
+ si.dwFlags = STARTF_USESTDHANDLES;
+ si.hStdOutput = wr;
+ si.hStdError = wr;
+ si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
+ PROCESS_INFORMATION pi = {0};
+
+ char *cmd_mut = _strdup(cmdline);
+ if (!cmd_mut) {
+ CloseHandle(rd);
+ CloseHandle(wr);
+ return -1;
+ }
+ BOOL ok = CreateProcessA(NULL, cmd_mut, NULL, NULL, TRUE, 0, NULL,
+ cwd && cwd[0] ? cwd : NULL, &si, &pi);
+ free(cmd_mut);
+ CloseHandle(wr);
+ if (!ok) {
+ CloseHandle(rd);
+ return -1;
+ }
+
+ size_t cap = 4096, len = 0;
+ char *buf = malloc(cap);
+ if (!buf) {
+ CloseHandle(rd);
+ CloseHandle(pi.hProcess);
+ CloseHandle(pi.hThread);
+ return -1;
+ }
+ for (;;) {
+ char tmp[1024];
+ DWORD n = 0;
+ if (!ReadFile(rd, tmp, sizeof(tmp), &n, NULL) || n == 0)
+ break;
+ if (len + n + 1 > cap) {
+ size_t nc = cap * 2;
+ if (nc < len + n + 1)
+ nc = len + n + 1;
+ char *nb = realloc(buf, nc);
+ if (!nb)
+ break;
+ buf = nb;
+ cap = nc;
+ }
+ memcpy(buf + len, tmp, n);
+ len += n;
+ if (len >= max_out)
+ break;
+ }
+ CloseHandle(rd);
+ WaitForSingleObject(pi.hProcess, INFINITE);
+ DWORD code = 1;
+ GetExitCodeProcess(pi.hProcess, &code);
+ CloseHandle(pi.hProcess);
+ CloseHandle(pi.hThread);
+ buf[len] = 0;
+ r->out = buf;
+ r->out_len = len;
+ r->exit_code = (int)code;
+ return 0;
+}
+#else
+int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
+ os_run_result_t *r) {
+ if (!argv || !argv[0] || !r)
+ return -1;
+ if (max_out == 0)
+ max_out = 512 * 1024;
+ memset(r, 0, sizeof(*r));
+
+ int pipefd[2];
+ if (pipe(pipefd) != 0)
+ return -1;
+ pid_t pid = fork();
+ if (pid < 0) {
+ close(pipefd[0]);
+ close(pipefd[1]);
+ return -1;
+ }
+ if (pid == 0) {
+ close(pipefd[0]);
+ dup2(pipefd[1], STDOUT_FILENO);
+ dup2(pipefd[1], STDERR_FILENO);
+ close(pipefd[1]);
+ if (cwd && cwd[0] && chdir(cwd) != 0)
+ _exit(127);
+ execvp(argv[0], (char *const *)argv);
+ _exit(127);
+ }
+ close(pipefd[1]);
+ size_t cap = 4096, len = 0;
+ char *buf = malloc(cap);
+ if (!buf) {
+ close(pipefd[0]);
+ waitpid(pid, NULL, 0);
+ return -1;
+ }
+ for (;;) {
+ if (len + 1024 > cap) {
+ cap *= 2;
+ char *nb = realloc(buf, cap);
+ if (!nb)
+ break;
+ buf = nb;
+ }
+ ssize_t n = read(pipefd[0], buf + len, 1024);
+ if (n < 0) {
+ if (errno == EINTR)
+ continue;
+ break;
+ }
+ if (n == 0)
+ break;
+ len += (size_t)n;
+ if (len >= max_out)
+ break;
+ }
+ close(pipefd[0]);
+ int status = 0;
+ waitpid(pid, &status, 0);
+ buf[len] = 0;
+ r->out = buf;
+ r->out_len = len;
+ r->exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 1;
+ return 0;
+}
+#endif
+
+int os_run_shell_c(const char *cwd, const char *cmd, size_t max_out,
+ os_run_result_t *r) {
+ if (!cmd || !r)
+ return -1;
+ char shell[OS_PATH_MAX];
+ if (os_resolve_shell(shell, sizeof(shell)) != 0)
+ return -1;
+
+ /* busybox: busybox sh -c cmd ; /bin/sh: sh -c cmd */
+ int is_busybox = 0;
+ {
+ const char *base = shell;
+ const char *s = strrchr(shell, '/');
+#ifdef _WIN32
+ const char *s2 = strrchr(shell, '\\');
+ if (s2 && (!s || s2 > s))
+ s = s2;
+#endif
+ if (s)
+ base = s + 1;
+ if (strncmp(base, "busybox", 7) == 0)
+ is_busybox = 1;
+ }
+
+ if (is_busybox) {
+ const char *argv[] = {shell, "sh", "-c", cmd, NULL};
+ return os_run_capture(cwd, argv, max_out, r);
+ } else {
+ const char *argv[] = {shell, "-c", cmd, NULL};
+ return os_run_capture(cwd, argv, max_out, r);
+ }
+}
+
+void os_localtime(const time_t *t, struct tm *out) {
+ if (!t || !out)
+ return;
+#ifdef _WIN32
+ localtime_s(out, t);
+#else
+ localtime_r(t, out);
+#endif
+}
+
+int os_getpid(void) {
+#ifdef _WIN32
+ return (int)GetCurrentProcessId();
+#else
+ return (int)getpid();
+#endif
+}
+
+int os_chmod_private(const char *path) {
+ if (!path)
+ return -1;
+#ifdef _WIN32
+ (void)path;
+ return 0;
+#else
+ return chmod(path, 0600);
+#endif
+}
diff --git a/src/sandbox.c b/src/sandbox.c
new file mode 100644
index 0000000..870dedc
--- /dev/null
+++ b/src/sandbox.c
@@ -0,0 +1,61 @@
+#define _DEFAULT_SOURCE
+
+#include
+#include
+#include
+
+#include "cst_agent.h"
+#include "os_compat.h"
+
+int cst_sandbox_resolve(const char *workspace_root, const char *rel_or_abs,
+ char *out, size_t out_sz) {
+ if (!workspace_root || !rel_or_abs || !out || out_sz < 2)
+ return -1;
+
+ char root_real[OS_PATH_MAX];
+ if (os_realpath(workspace_root, root_real, sizeof(root_real)) != 0)
+ return -1;
+ os_path_norm_slashes(root_real);
+ size_t root_len = strlen(root_real);
+
+ char candidate[OS_PATH_MAX];
+ if (os_path_is_abs(rel_or_abs)) {
+ snprintf(candidate, sizeof(candidate), "%s", rel_or_abs);
+ } else if (os_path_join(candidate, sizeof(candidate), root_real, rel_or_abs) !=
+ 0) {
+ return -1;
+ }
+ os_path_norm_slashes(candidate);
+
+ char resolved[OS_PATH_MAX];
+ if (os_realpath(candidate, resolved, sizeof(resolved)) == 0) {
+ os_path_norm_slashes(resolved);
+ if (!os_path_under(root_real, resolved))
+ return -1;
+ if (strlen(resolved) + 1 > out_sz)
+ return -1;
+ memcpy(out, resolved, strlen(resolved) + 1);
+ return 0;
+ }
+
+ /* Parent exists, leaf may be new file. */
+ char tmp[OS_PATH_MAX];
+ snprintf(tmp, sizeof(tmp), "%s", candidate);
+ char *slash = strrchr(tmp, '/');
+ if (!slash)
+ return -1;
+ *slash = 0;
+ const char *base = slash + 1;
+ char parent_real[OS_PATH_MAX];
+ if (os_realpath(tmp, parent_real, sizeof(parent_real)) != 0)
+ return -1;
+ os_path_norm_slashes(parent_real);
+ if (!os_path_under(root_real, parent_real) &&
+ strcmp(parent_real, root_real) != 0)
+ return -1;
+ (void)root_len;
+ int n = snprintf(out, out_sz, "%s/%s", parent_real, base);
+ if (n < 0 || (size_t)n >= out_sz)
+ return -1;
+ return 0;
+}
diff --git a/src/session.c b/src/session.c
new file mode 100644
index 0000000..7aeaaef
--- /dev/null
+++ b/src/session.c
@@ -0,0 +1,363 @@
+#define _DEFAULT_SOURCE
+#ifndef _GNU_SOURCE
+#define _GNU_SOURCE
+#endif
+
+#include "cst_agent.h"
+
+#include
+#include
+#include
+
+#ifdef _WIN32
+typedef long long cst_ssize_t;
+static cst_ssize_t cst_getline(char **lineptr, size_t *n, FILE *stream) {
+ if (!lineptr || !n || !stream)
+ return -1;
+ if (!*lineptr || *n == 0) {
+ *n = 256;
+ *lineptr = (char *)malloc(*n);
+ if (!*lineptr)
+ return -1;
+ }
+ size_t len = 0;
+ int c = 0;
+ while ((c = fgetc(stream)) != EOF) {
+ if (len + 2 > *n) {
+ size_t nn = (*n) * 2;
+ char *nb = (char *)realloc(*lineptr, nn);
+ if (!nb)
+ return -1;
+ *lineptr = nb;
+ *n = nn;
+ }
+ (*lineptr)[len++] = (char)c;
+ if (c == '\n')
+ break;
+ }
+ if (len == 0 && c == EOF)
+ return -1;
+ (*lineptr)[len] = 0;
+ return (cst_ssize_t)len;
+}
+#define getline cst_getline
+#endif
+
+struct cst_session {
+ char path[4096];
+ cst_message_t *msgs;
+ int nmsgs;
+ int cap;
+ FILE *fp;
+};
+
+static char *xstrdup(const char *s) {
+ if (!s)
+ return NULL;
+ size_t n = strlen(s);
+ char *p = malloc(n + 1);
+ if (!p)
+ return NULL;
+ memcpy(p, s, n + 1);
+ return p;
+}
+
+static const char *role_str(cst_role_t r) {
+ switch (r) {
+ case CST_ROLE_SYSTEM:
+ return "system";
+ case CST_ROLE_USER:
+ return "user";
+ case CST_ROLE_ASSISTANT:
+ return "assistant";
+ case CST_ROLE_TOOL:
+ return "tool";
+ }
+ return "user";
+}
+
+static cst_role_t role_from(const char *s) {
+ if (strcmp(s, "system") == 0)
+ return CST_ROLE_SYSTEM;
+ if (strcmp(s, "assistant") == 0)
+ return CST_ROLE_ASSISTANT;
+ if (strcmp(s, "tool") == 0)
+ return CST_ROLE_TOOL;
+ return CST_ROLE_USER;
+}
+
+static int json_escape_write(FILE *fp, const char *s) {
+ fputc('"', fp);
+ for (; s && *s; s++) {
+ unsigned char c = (unsigned char)*s;
+ if (c == '"' || c == '\\') {
+ fputc('\\', fp);
+ fputc(c, fp);
+ } else if (c == '\n') {
+ fputs("\\n", fp);
+ } else if (c == '\r') {
+ fputs("\\r", fp);
+ } else if (c == '\t') {
+ fputs("\\t", fp);
+ } else if (c < 0x20) {
+ fprintf(fp, "\\u%04x", c);
+ } else {
+ fputc(c, fp);
+ }
+ }
+ fputc('"', fp);
+ return 0;
+}
+
+/* Minimal extract "key":"value" from a single JSONL object line. */
+static int extract_str(const char *line, const char *key, char *out,
+ size_t out_sz) {
+ char pat[64];
+ snprintf(pat, sizeof(pat), "\"%s\":\"", key);
+ const char *p = strstr(line, pat);
+ if (!p)
+ return -1;
+ p += strlen(pat);
+ size_t o = 0;
+ while (*p && o + 1 < out_sz) {
+ if (*p == '\\' && p[1]) {
+ p++;
+ if (*p == 'n')
+ out[o++] = '\n';
+ else if (*p == 'r')
+ out[o++] = '\r';
+ else if (*p == 't')
+ out[o++] = '\t';
+ else
+ out[o++] = *p;
+ p++;
+ continue;
+ }
+ if (*p == '"') {
+ out[o] = 0;
+ return 0;
+ }
+ out[o++] = *p++;
+ }
+ return -1;
+}
+
+static int session_grow(cst_session_t *s) {
+ if (s->nmsgs < s->cap)
+ return 0;
+ int ncap = s->cap ? s->cap * 2 : 32;
+ cst_message_t *nm = realloc(s->msgs, (size_t)ncap * sizeof(cst_message_t));
+ if (!nm)
+ return -1;
+ s->msgs = nm;
+ s->cap = ncap;
+ return 0;
+}
+
+static int session_push_mem(cst_session_t *s, cst_role_t role, const char *content,
+ const char *tool_id, const char *tool_name,
+ const char *tool_calls_json) {
+ if (session_grow(s) != 0)
+ return -1;
+ cst_message_t *d = &s->msgs[s->nmsgs];
+ memset(d, 0, sizeof(*d));
+ d->role = role;
+ d->content = xstrdup(content ? content : "");
+ if (!d->content)
+ return -1;
+ if (tool_id)
+ snprintf(d->tool_call_id, sizeof(d->tool_call_id), "%s", tool_id);
+ if (tool_name)
+ snprintf(d->tool_name, sizeof(d->tool_name), "%s", tool_name);
+ if (tool_calls_json && tool_calls_json[0])
+ d->tool_calls_json = xstrdup(tool_calls_json);
+ s->nmsgs++;
+ return 0;
+}
+
+static void load_existing(cst_session_t *s) {
+ FILE *rp = fopen(s->path, "r");
+ if (!rp)
+ return;
+ char *line = NULL;
+ size_t cap = 0;
+ while (getline(&line, &cap, rp) != -1) {
+ char role[32] = {0};
+ char *content = malloc(cap > 8 ? cap : 4096);
+ char tid[CTL_MAX_ID] = {0};
+ char tname[CTL_MAX_NAME] = {0};
+ if (!content)
+ continue;
+ content[0] = 0;
+ if (extract_str(line, "role", role, sizeof(role)) != 0) {
+ free(content);
+ continue;
+ }
+ /* content may be large — reuse line extract into growing buffer */
+ free(content);
+ content = malloc(strlen(line) + 1);
+ if (!content)
+ continue;
+ if (extract_str(line, "content", content, strlen(line) + 1) != 0) {
+ free(content);
+ continue;
+ }
+ extract_str(line, "tool_call_id", tid, sizeof(tid));
+ extract_str(line, "tool_name", tname, sizeof(tname));
+ char *tcj = malloc(strlen(line) + 1);
+ const char *tcjp = NULL;
+ if (tcj) {
+ tcj[0] = 0;
+ if (extract_str(line, "tool_calls_json", tcj, strlen(line) + 1) == 0)
+ tcjp = tcj;
+ }
+ session_push_mem(s, role_from(role), content, tid[0] ? tid : NULL,
+ tname[0] ? tname : NULL, tcjp);
+ free(tcj);
+ free(content);
+ }
+ free(line);
+ fclose(rp);
+}
+
+cst_session_t *cst_session_open(const char *path) {
+ cst_session_t *s = calloc(1, sizeof(*s));
+ if (!s)
+ return NULL;
+ snprintf(s->path, sizeof(s->path), "%s", path ? path : "session.jsonl");
+ load_existing(s);
+ s->fp = fopen(s->path, "a+");
+ if (!s->fp) {
+ for (int i = 0; i < s->nmsgs; i++) {
+ free(s->msgs[i].content);
+ free(s->msgs[i].tool_calls_json);
+ }
+ free(s->msgs);
+ free(s);
+ return NULL;
+ }
+ if (!s->msgs) {
+ s->cap = 32;
+ s->msgs = calloc((size_t)s->cap, sizeof(cst_message_t));
+ if (!s->msgs) {
+ fclose(s->fp);
+ free(s);
+ return NULL;
+ }
+ }
+ return s;
+}
+
+void cst_session_close(cst_session_t *s) {
+ if (!s)
+ return;
+ for (int i = 0; i < s->nmsgs; i++) {
+ free(s->msgs[i].content);
+ free(s->msgs[i].tool_calls_json);
+ }
+ free(s->msgs);
+ if (s->fp)
+ fclose(s->fp);
+ free(s);
+}
+
+int cst_session_append(cst_session_t *s, const cst_message_t *msg) {
+ if (!s || !msg)
+ return -1;
+ if (session_push_mem(s, msg->role, msg->content, msg->tool_call_id,
+ msg->tool_name, msg->tool_calls_json) != 0)
+ return -1;
+
+ fprintf(s->fp, "{\"role\":");
+ json_escape_write(s->fp, role_str(msg->role));
+ fprintf(s->fp, ",\"content\":");
+ json_escape_write(s->fp, msg->content ? msg->content : "");
+ if (msg->tool_call_id[0]) {
+ fprintf(s->fp, ",\"tool_call_id\":");
+ json_escape_write(s->fp, msg->tool_call_id);
+ }
+ if (msg->tool_name[0]) {
+ fprintf(s->fp, ",\"tool_name\":");
+ json_escape_write(s->fp, msg->tool_name);
+ }
+ if (msg->tool_calls_json && msg->tool_calls_json[0]) {
+ fprintf(s->fp, ",\"tool_calls_json\":");
+ json_escape_write(s->fp, msg->tool_calls_json);
+ }
+ fprintf(s->fp, "}\n");
+ fflush(s->fp);
+ return 0;
+}
+
+int cst_session_count(const cst_session_t *s) {
+ return s ? s->nmsgs : 0;
+}
+
+const cst_message_t *cst_session_get(const cst_session_t *s, int i) {
+ if (!s || i < 0 || i >= s->nmsgs)
+ return NULL;
+ return &s->msgs[i];
+}
+
+const char *cst_session_path(const cst_session_t *s) {
+ return s ? s->path : NULL;
+}
+
+int cst_session_clear(cst_session_t *s, int keep_system) {
+ if (!s)
+ return -1;
+ cst_message_t sys = {0};
+ int have_sys = 0;
+ if (keep_system) {
+ for (int i = 0; i < s->nmsgs; i++) {
+ if (s->msgs[i].role == CST_ROLE_SYSTEM) {
+ sys.role = CST_ROLE_SYSTEM;
+ sys.content = xstrdup(s->msgs[i].content ? s->msgs[i].content : "");
+ have_sys = 1;
+ break;
+ }
+ }
+ }
+ for (int i = 0; i < s->nmsgs; i++) {
+ free(s->msgs[i].content);
+ free(s->msgs[i].tool_calls_json);
+ }
+ s->nmsgs = 0;
+ if (s->fp) {
+ fclose(s->fp);
+ s->fp = NULL;
+ }
+ s->fp = fopen(s->path, "w+");
+ if (!s->fp) {
+ if (have_sys)
+ free(sys.content);
+ return -1;
+ }
+ if (have_sys) {
+ cst_session_append(s, &sys);
+ free(sys.content);
+ }
+ return 0;
+}
+
+int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n) {
+ if (!s || n < 0)
+ return -1;
+ for (int i = 0; i < s->nmsgs; i++) {
+ free(s->msgs[i].content);
+ free(s->msgs[i].tool_calls_json);
+ }
+ s->nmsgs = 0;
+ if (s->fp) {
+ fclose(s->fp);
+ s->fp = NULL;
+ }
+ s->fp = fopen(s->path, "w+");
+ if (!s->fp)
+ return -1;
+ for (int i = 0; i < n; i++) {
+ if (cst_session_append(s, &msgs[i]) != 0)
+ return -1;
+ }
+ return 0;
+}
diff --git a/src/tools/tools.c b/src/tools/tools.c
new file mode 100644
index 0000000..df5afc4
--- /dev/null
+++ b/src/tools/tools.c
@@ -0,0 +1,784 @@
+#define _DEFAULT_SOURCE
+
+#include
+#include
+#include
+#include
+
+#include "cst_agent.h"
+#include "os_compat.h"
+
+#ifndef _WIN32
+#include
+#else
+#include
+#include
+#endif
+
+void cst_tool_result_free(cst_tool_result_t *r) {
+ if (!r)
+ return;
+ free(r->out);
+ r->out = NULL;
+ r->out_len = 0;
+}
+
+static const char *arg_get(const ctl_tool_call_t *call, const char *key) {
+ for (int i = 0; i < call->nargs; i++) {
+ if (strcmp(call->args[i].key, key) == 0)
+ return call->args[i].value ? call->args[i].value : "";
+ }
+ return NULL;
+}
+
+static int set_result(cst_tool_result_t *r, const char *s, int code) {
+ cst_tool_result_free(r);
+ size_t n = s ? strlen(s) : 0;
+ r->out = malloc(n + 1);
+ if (!r->out)
+ return -1;
+ if (n)
+ memcpy(r->out, s, n);
+ r->out[n] = 0;
+ r->out_len = n;
+ r->exit_code = code;
+ return 0;
+}
+
+/* Read whole file into malloc'd buffer; *out_len set. Returns NULL if missing. */
+static char *slurp_file(const char *full, size_t *out_len) {
+ *out_len = 0;
+ FILE *fp = fopen(full, "rb");
+ if (!fp)
+ return NULL;
+ if (fseek(fp, 0, SEEK_END) != 0) {
+ fclose(fp);
+ return NULL;
+ }
+ long sz = ftell(fp);
+ if (sz < 0) {
+ fclose(fp);
+ return NULL;
+ }
+ rewind(fp);
+ char *buf = malloc((size_t)sz + 1);
+ if (!buf) {
+ fclose(fp);
+ return NULL;
+ }
+ size_t n = fread(buf, 1, (size_t)sz, fp);
+ fclose(fp);
+ buf[n] = 0;
+ *out_len = n;
+ return buf;
+}
+
+#define DIFF_OUT_CAP (48 * 1024)
+
+/* OpenCode-style: always surface a unified diff after write/patch. */
+static char *make_unified_diff(const char *relpath, const char *oldc, size_t olen,
+ const char *newc, size_t nlen) {
+ char t0[OS_PATH_MAX], t1[OS_PATH_MAX];
+ int fd0 = os_mkstemp(t0, sizeof(t0), "cstda");
+ int fd1 = os_mkstemp(t1, sizeof(t1), "cstdb");
+ if (fd0 < 0 || fd1 < 0) {
+ if (fd0 >= 0) {
+#ifdef _WIN32
+ _close(fd0);
+ _unlink(t0);
+#else
+ close(fd0);
+ unlink(t0);
+#endif
+ }
+ if (fd1 >= 0) {
+#ifdef _WIN32
+ _close(fd1);
+ _unlink(t1);
+#else
+ close(fd1);
+ unlink(t1);
+#endif
+ }
+ return NULL;
+ }
+ if (olen) {
+#ifdef _WIN32
+ (void)_write(fd0, oldc, (unsigned)olen);
+#else
+ (void)write(fd0, oldc, olen);
+#endif
+ }
+ if (nlen) {
+#ifdef _WIN32
+ (void)_write(fd1, newc, (unsigned)nlen);
+#else
+ (void)write(fd1, newc, nlen);
+#endif
+ }
+#ifdef _WIN32
+ _close(fd0);
+ _close(fd1);
+#else
+ close(fd0);
+ close(fd1);
+#endif
+
+ const char *label = relpath && relpath[0] ? relpath : "file";
+ char cmd[OS_PATH_MAX * 2 + 256];
+ snprintf(cmd, sizeof(cmd),
+ "diff -u --label a/%s --label b/%s \"%s\" \"%s\"", label, label, t0,
+ t1);
+
+ os_run_result_t rr = {0};
+ int rc = os_run_shell_c(NULL, cmd, DIFF_OUT_CAP, &rr);
+#ifdef _WIN32
+ _unlink(t0);
+ _unlink(t1);
+#else
+ unlink(t0);
+ unlink(t1);
+#endif
+ if (rc != 0) {
+ os_run_result_free(&rr);
+ return strdup("(no textual diff — diff unavailable)\n");
+ }
+ /* diff returns 1 when files differ — still success for our purposes */
+ if (!rr.out || rr.out_len == 0) {
+ os_run_result_free(&rr);
+ return strdup("(no textual diff — identical or binary)\n");
+ }
+ char *out = rr.out;
+ rr.out = NULL;
+ size_t len = rr.out_len;
+ os_run_result_free(&rr);
+ if (len >= DIFF_OUT_CAP - 64) {
+ snprintf(out + DIFF_OUT_CAP - 64, 64, "\n…(diff truncated)\n");
+ }
+ return out;
+}
+
+static int set_result_with_diff(cst_tool_result_t *r, const char *relpath,
+ const char *oldc, size_t olen, const char *newc,
+ size_t nlen, int code) {
+ char *diff = make_unified_diff(relpath, oldc ? oldc : "", olen, newc ? newc : "",
+ nlen);
+ char hdr[256];
+ snprintf(hdr, sizeof(hdr), "wrote %s (%zu bytes)\n",
+ relpath ? relpath : "?", nlen);
+ size_t hlen = strlen(hdr);
+ size_t dlen = diff ? strlen(diff) : 0;
+ char *buf = malloc(hlen + dlen + 1);
+ if (!buf) {
+ free(diff);
+ return set_result(r, "ok", code);
+ }
+ memcpy(buf, hdr, hlen);
+ if (diff && dlen)
+ memcpy(buf + hlen, diff, dlen);
+ buf[hlen + dlen] = 0;
+ free(diff);
+ cst_tool_result_free(r);
+ r->out = buf;
+ r->out_len = hlen + dlen;
+ r->exit_code = code;
+ return 0;
+}
+
+int cst_tool_read_file(const char *workspace_root, const ctl_tool_call_t *call,
+ cst_tool_result_t *r) {
+ const char *path = arg_get(call, "path");
+ if (!path)
+ return set_result(r, "error: missing path", 1);
+ char full[4096];
+ if (cst_sandbox_resolve(workspace_root, path, full, sizeof(full)) != 0)
+ return set_result(r, "error: path outside workspace", 1);
+ FILE *fp = fopen(full, "rb");
+ if (!fp)
+ return set_result(r, "error: cannot open file", 1);
+ if (fseek(fp, 0, SEEK_END) != 0) {
+ fclose(fp);
+ return set_result(r, "error: seek failed", 1);
+ }
+ long sz = ftell(fp);
+ if (sz < 0 || sz > 2 * 1024 * 1024) {
+ fclose(fp);
+ return set_result(r, "error: file too large or empty seek", 1);
+ }
+ rewind(fp);
+ char *buf = malloc((size_t)sz + 1);
+ if (!buf) {
+ fclose(fp);
+ return set_result(r, "error: oom", 1);
+ }
+ size_t n = fread(buf, 1, (size_t)sz, fp);
+ fclose(fp);
+ buf[n] = 0;
+ cst_tool_result_free(r);
+ r->out = buf;
+ r->out_len = n;
+ r->exit_code = 0;
+ return 0;
+}
+
+int cst_tool_write_file(const char *workspace_root, const ctl_tool_call_t *call,
+ cst_tool_result_t *r) {
+ const char *path = arg_get(call, "path");
+ const char *content = arg_get(call, "content");
+ if (!path || !content)
+ return set_result(r, "error: missing path or content", 1);
+ char full[4096];
+ if (cst_sandbox_resolve(workspace_root, path, full, sizeof(full)) != 0)
+ return set_result(r, "error: path outside workspace", 1);
+
+ size_t olen = 0;
+ char *oldc = slurp_file(full, &olen);
+
+ FILE *fp = fopen(full, "wb");
+ if (!fp) {
+ free(oldc);
+ return set_result(r, "error: cannot write file", 1);
+ }
+ size_t n = strlen(content);
+ if (fwrite(content, 1, n, fp) != n) {
+ fclose(fp);
+ free(oldc);
+ return set_result(r, "error: write failed", 1);
+ }
+ fclose(fp);
+ int rc = set_result_with_diff(r, path, oldc, olen, content, n, 0);
+ free(oldc);
+ return rc;
+}
+
+int cst_tool_run_shell(const char *workspace_root, const ctl_tool_call_t *call,
+ cst_tool_result_t *r) {
+ const char *cmd = arg_get(call, "cmd");
+ if (!cmd)
+ cmd = arg_get(call, "command");
+ if (!cmd)
+ return set_result(r, "error: missing cmd", 1);
+
+ char root_real[OS_PATH_MAX];
+ if (os_realpath(workspace_root, root_real, sizeof(root_real)) != 0)
+ return set_result(r, "error: bad workspace", 1);
+
+ os_run_result_t rr = {0};
+ if (os_run_shell_c(root_real, cmd, 512 * 1024, &rr) != 0) {
+ os_run_result_free(&rr);
+ return set_result(r, "error: shell exec failed", 1);
+ }
+ cst_tool_result_free(r);
+ r->out = rr.out;
+ r->out_len = rr.out_len;
+ r->exit_code = rr.exit_code;
+ rr.out = NULL;
+ os_run_result_free(&rr);
+ return 0;
+}
+
+/* Minimal unified diff apply: only support *** Update File: / write via
+ * apply_patch tool with arg path + content for MVP simplicity is write_file.
+ * For apply_patch: expect arg "patch" with a simple format:
+ * *** Update File:
+ *
+ * Or classic unified diff for single file with ---/+++ and @@ hunks.
+ */
+
+static int apply_full_replace(const char *workspace_root, const char *path,
+ const char *content, cst_tool_result_t *r) {
+ ctl_tool_call_t fake = {0};
+ snprintf(fake.name, sizeof(fake.name), "write_file");
+ fake.nargs = 2;
+ snprintf(fake.args[0].key, sizeof(fake.args[0].key), "path");
+ fake.args[0].value = (char *)path;
+ fake.args[0].value_len = strlen(path);
+ snprintf(fake.args[1].key, sizeof(fake.args[1].key), "content");
+ fake.args[1].value = (char *)content;
+ fake.args[1].value_len = strlen(content);
+ return cst_tool_write_file(workspace_root, &fake, r);
+}
+
+/* Apply one complete unified-diff hunk to a malloc'd file buffer.
+ * Returns 0 on success. Very small subset: context/add/remove lines. */
+static int apply_hunk(char **file, size_t *flen, const char *hunk,
+ size_t hunk_len) {
+ /* Find @@ line */
+ const char *p = hunk;
+ const char *end = hunk + hunk_len;
+ while (p < end && !(*p == '@' && p + 1 < end && p[1] == '@')) {
+ while (p < end && *p != '\n')
+ p++;
+ if (p < end)
+ p++;
+ }
+ if (p >= end)
+ return -1;
+ while (p < end && *p != '\n')
+ p++;
+ if (p < end)
+ p++;
+
+ /* Parse old file into lines */
+ size_t nlines = 0;
+ for (size_t i = 0; i < *flen; i++)
+ if ((*file)[i] == '\n')
+ nlines++;
+ if (*flen > 0 && (*file)[*flen - 1] != '\n')
+ nlines++;
+ char **lines = calloc(nlines + 64, sizeof(char *));
+ size_t *llens = calloc(nlines + 64, sizeof(size_t));
+ if (!lines || !llens) {
+ free(lines);
+ free(llens);
+ return -1;
+ }
+ size_t li = 0, start = 0;
+ for (size_t i = 0; i <= *flen; i++) {
+ if (i == *flen || (*file)[i] == '\n') {
+ size_t L = i - start;
+ lines[li] = malloc(L + 1);
+ if (!lines[li]) {
+ for (size_t k = 0; k < li; k++)
+ free(lines[k]);
+ free(lines);
+ free(llens);
+ return -1;
+ }
+ memcpy(lines[li], *file + start, L);
+ lines[li][L] = 0;
+ llens[li] = L;
+ li++;
+ start = i + 1;
+ }
+ }
+ size_t cur = 0;
+ /* Walk hunk body */
+ while (p < end) {
+ char tag = *p;
+ const char *ls = p + 1;
+ const char *le = ls;
+ while (le < end && *le != '\n')
+ le++;
+ size_t L = (size_t)(le - ls);
+ if (tag == ' ' || tag == '-') {
+ if (cur >= li || llens[cur] != L || memcmp(lines[cur], ls, L) != 0) {
+ for (size_t k = 0; k < li; k++)
+ free(lines[k]);
+ free(lines);
+ free(llens);
+ return -1;
+ }
+ if (tag == '-') {
+ free(lines[cur]);
+ memmove(&lines[cur], &lines[cur + 1], (li - cur - 1) * sizeof(char *));
+ memmove(&llens[cur], &llens[cur + 1], (li - cur - 1) * sizeof(size_t));
+ li--;
+ } else {
+ cur++;
+ }
+ } else if (tag == '+') {
+ char *nl = malloc(L + 1);
+ if (!nl) {
+ for (size_t k = 0; k < li; k++)
+ free(lines[k]);
+ free(lines);
+ free(llens);
+ return -1;
+ }
+ memcpy(nl, ls, L);
+ nl[L] = 0;
+ lines = realloc(lines, (li + 2) * sizeof(char *));
+ llens = realloc(llens, (li + 2) * sizeof(size_t));
+ memmove(&lines[cur + 1], &lines[cur], (li - cur) * sizeof(char *));
+ memmove(&llens[cur + 1], &llens[cur], (li - cur) * sizeof(size_t));
+ lines[cur] = nl;
+ llens[cur] = L;
+ li++;
+ cur++;
+ } else if (tag == '\\') {
+ /* "\ No newline at end of file" — ignore */
+ } else {
+ for (size_t k = 0; k < li; k++)
+ free(lines[k]);
+ free(lines);
+ free(llens);
+ return -1;
+ }
+ p = (le < end) ? le + 1 : end;
+ }
+ /* rebuild */
+ size_t total = 0;
+ for (size_t k = 0; k < li; k++)
+ total += llens[k] + 1;
+ char *out = malloc(total + 1);
+ if (!out) {
+ for (size_t k = 0; k < li; k++)
+ free(lines[k]);
+ free(lines);
+ free(llens);
+ return -1;
+ }
+ size_t o = 0;
+ for (size_t k = 0; k < li; k++) {
+ memcpy(out + o, lines[k], llens[k]);
+ o += llens[k];
+ out[o++] = '\n';
+ free(lines[k]);
+ }
+ free(lines);
+ free(llens);
+ free(*file);
+ *file = out;
+ *flen = o;
+ return 0;
+}
+
+int cst_tool_apply_patch(const char *workspace_root, const ctl_tool_call_t *call,
+ cst_tool_result_t *r) {
+ const char *patch = arg_get(call, "patch");
+ if (!patch)
+ return set_result(r, "error: missing patch", 1);
+
+ /* Full-file replace shorthand */
+ if (strncmp(patch, "*** Update File: ", 17) == 0) {
+ const char *nl = strchr(patch, '\n');
+ if (!nl)
+ return set_result(r, "error: incomplete Update File header", 1);
+ char path[1024];
+ size_t plen = (size_t)(nl - (patch + 17));
+ if (plen >= sizeof(path))
+ return set_result(r, "error: path too long", 1);
+ memcpy(path, patch + 17, plen);
+ path[plen] = 0;
+ char full[4096];
+ if (cst_sandbox_resolve(workspace_root, path, full, sizeof(full)) != 0)
+ return set_result(r, "error: path outside workspace", 1);
+ size_t olen = 0;
+ char *oldc = slurp_file(full, &olen);
+ const char *neu = nl + 1;
+ size_t nlen = strlen(neu);
+ ctl_tool_call_t fake = {0};
+ snprintf(fake.name, sizeof(fake.name), "write_file");
+ fake.nargs = 2;
+ snprintf(fake.args[0].key, sizeof(fake.args[0].key), "path");
+ fake.args[0].value = path;
+ fake.args[0].value_len = strlen(path);
+ snprintf(fake.args[1].key, sizeof(fake.args[1].key), "content");
+ fake.args[1].value = (char *)neu;
+ fake.args[1].value_len = nlen;
+ /* write_file already emits diff; free unused oldc */
+ free(oldc);
+ return cst_tool_write_file(workspace_root, &fake, r);
+ }
+
+ /* Unified diff: require complete hunks (each @@ ... until next @@ or EOF) */
+ const char *p = patch;
+ char *rel = NULL;
+ /* find +++ b/path */
+ const char *plus = strstr(p, "\n+++ ");
+ if (!plus)
+ plus = (strncmp(p, "+++ ", 4) == 0) ? p : NULL;
+ if (!plus)
+ return set_result(r, "error: missing +++ line (incomplete patch)", 1);
+ const char *ps = plus + (plus[0] == '\n' ? 5 : 4);
+ if (ps[0] == 'b' && ps[1] == '/')
+ ps += 2;
+ const char *pe = strchr(ps, '\n');
+ if (!pe)
+ return set_result(r, "error: incomplete +++ line", 1);
+ size_t rlen = (size_t)(pe - ps);
+ rel = malloc(rlen + 1);
+ if (!rel)
+ return set_result(r, "error: oom", 1);
+ memcpy(rel, ps, rlen);
+ rel[rlen] = 0;
+
+ char full[4096];
+ if (cst_sandbox_resolve(workspace_root, rel, full, sizeof(full)) != 0) {
+ free(rel);
+ return set_result(r, "error: path outside workspace", 1);
+ }
+ char *relpath_for_diff = rel; /* owned until return */
+
+ FILE *fp = fopen(full, "rb");
+ char *file = NULL;
+ size_t flen = 0;
+ if (fp) {
+ fseek(fp, 0, SEEK_END);
+ long sz = ftell(fp);
+ rewind(fp);
+ if (sz < 0) {
+ fclose(fp);
+ free(relpath_for_diff);
+ return set_result(r, "error: ftell", 1);
+ }
+ file = malloc((size_t)sz + 1);
+ if (!file) {
+ fclose(fp);
+ free(relpath_for_diff);
+ return set_result(r, "error: oom", 1);
+ }
+ flen = fread(file, 1, (size_t)sz, fp);
+ fclose(fp);
+ file[flen] = 0;
+ } else {
+ file = calloc(1, 1);
+ flen = 0;
+ }
+
+ /* Snapshot before mutating hunks. */
+ char *old_snapshot = NULL;
+ size_t old_len = flen;
+ if (flen) {
+ old_snapshot = malloc(flen + 1);
+ if (old_snapshot) {
+ memcpy(old_snapshot, file, flen);
+ old_snapshot[flen] = 0;
+ }
+ } else {
+ old_snapshot = calloc(1, 1);
+ old_len = 0;
+ }
+
+ /* Split into hunks starting at @@ */
+ const char *h = strstr(pe, "\n@@");
+ if (!h)
+ h = strstr(pe, "@@");
+ if (!h) {
+ free(file);
+ free(old_snapshot);
+ free(relpath_for_diff);
+ return set_result(r, "error: no complete hunk", 1);
+ }
+ if (*h == '\n')
+ h++;
+ while (h && *h == '@') {
+ const char *next = strstr(h + 2, "\n@@");
+ size_t hlen;
+ if (next) {
+ hlen = (size_t)(next + 1 - h);
+ } else {
+ hlen = strlen(h);
+ }
+ const char *hnl = strchr(h, '\n');
+ if (!hnl || hnl >= h + hlen - 1) {
+ free(file);
+ free(old_snapshot);
+ free(relpath_for_diff);
+ return set_result(r, "error: incomplete hunk (not confirmed)", 1);
+ }
+ if (apply_hunk(&file, &flen, h, hlen) != 0) {
+ free(file);
+ free(old_snapshot);
+ free(relpath_for_diff);
+ return set_result(r, "error: hunk apply failed", 1);
+ }
+ h = next ? next + 1 : NULL;
+ }
+
+ fp = fopen(full, "wb");
+ if (!fp) {
+ free(file);
+ free(old_snapshot);
+ free(relpath_for_diff);
+ return set_result(r, "error: cannot write", 1);
+ }
+ if (fwrite(file, 1, flen, fp) != flen) {
+ fclose(fp);
+ free(file);
+ free(old_snapshot);
+ free(relpath_for_diff);
+ return set_result(r, "error: write failed", 1);
+ }
+ fclose(fp);
+
+ /* Patch text is already a diff — show it when short. */
+ size_t plen = strlen(patch);
+ if (plen > 0 && plen < DIFF_OUT_CAP) {
+ char hdr[256];
+ snprintf(hdr, sizeof(hdr), "applied patch → %s\n", relpath_for_diff);
+ size_t hlen = strlen(hdr);
+ char *buf = malloc(hlen + plen + 1);
+ if (buf) {
+ memcpy(buf, hdr, hlen);
+ memcpy(buf + hlen, patch, plen);
+ buf[hlen + plen] = 0;
+ cst_tool_result_free(r);
+ r->out = buf;
+ r->out_len = hlen + plen;
+ r->exit_code = 0;
+ free(file);
+ free(old_snapshot);
+ free(relpath_for_diff);
+ return 0;
+ }
+ }
+ int rc = set_result_with_diff(r, relpath_for_diff, old_snapshot, old_len, file,
+ flen, 0);
+ free(file);
+ free(old_snapshot);
+ free(relpath_for_diff);
+ return rc;
+}
+
+
+int cst_tool_dispatch(const char *workspace_root, const ctl_tool_call_t *call,
+ cst_tool_result_t *r) {
+ if (!call || !r)
+ return -1;
+ memset(r, 0, sizeof(*r));
+ if (strcmp(call->name, "read_file") == 0)
+ return cst_tool_read_file(workspace_root, call, r);
+ if (strcmp(call->name, "run_shell") == 0)
+ return cst_tool_run_shell(workspace_root, call, r);
+ if (strcmp(call->name, "apply_patch") == 0)
+ return cst_tool_apply_patch(workspace_root, call, r);
+ if (strcmp(call->name, "write_file") == 0)
+ return cst_tool_write_file(workspace_root, call, r);
+ return set_result(r, "error: unknown tool", 1);
+}
+
+/* Parse JSON string at *pp (must point at '"'); advances *pp past closing quote.
+ * Returns malloc'd decoded value. */
+static char *parse_json_string(const char **pp, size_t *out_len) {
+ const char *p = *pp;
+ if (!p || *p != '"')
+ return NULL;
+ p++;
+ size_t cap = 64, len = 0;
+ char *out = malloc(cap);
+ if (!out)
+ return NULL;
+ while (*p) {
+ char ch;
+ if (*p == '\\' && p[1]) {
+ p++;
+ if (*p == 'n')
+ ch = '\n';
+ else if (*p == 'r')
+ ch = '\r';
+ else if (*p == 't')
+ ch = '\t';
+ else
+ ch = *p;
+ p++;
+ } else if (*p == '"') {
+ p++;
+ out[len] = 0;
+ *pp = p;
+ if (out_len)
+ *out_len = len;
+ return out;
+ } else {
+ ch = *p++;
+ }
+ if (len + 2 > cap) {
+ cap *= 2;
+ char *nb = realloc(out, cap);
+ if (!nb) {
+ free(out);
+ return NULL;
+ }
+ out = nb;
+ }
+ out[len++] = ch;
+ }
+ free(out);
+ return NULL;
+}
+
+int cst_tool_call_from_native(ctl_tool_call_t *out, const char *id,
+ const char *name, const char *args_json) {
+ if (!out || !name || !name[0])
+ return -1;
+ memset(out, 0, sizeof(*out));
+ if (id)
+ snprintf(out->id, sizeof(out->id), "%s", id);
+ snprintf(out->name, sizeof(out->name), "%s", name);
+ if (!args_json)
+ return 0;
+
+ const char *p = args_json;
+ while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t')
+ p++;
+ if (*p != '{')
+ return 0;
+ p++;
+
+ while (*p) {
+ while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t' || *p == ',')
+ p++;
+ if (*p == '}' || !*p)
+ break;
+ if (*p != '"')
+ break;
+
+ size_t klen = 0;
+ char *key = parse_json_string(&p, &klen);
+ if (!key)
+ break;
+ while (*p == ' ' || *p == '\t')
+ p++;
+ if (*p != ':') {
+ free(key);
+ break;
+ }
+ p++;
+ while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')
+ p++;
+
+ if (*p == '"') {
+ size_t vlen = 0;
+ char *val = parse_json_string(&p, &vlen);
+ if (!val) {
+ free(key);
+ break;
+ }
+ if (out->nargs < CTL_MAX_ARGS && klen < CTL_MAX_KEY) {
+ snprintf(out->args[out->nargs].key, CTL_MAX_KEY, "%s", key);
+ out->args[out->nargs].value = val;
+ out->args[out->nargs].value_len = vlen;
+ out->nargs++;
+ } else {
+ free(val);
+ }
+ free(key);
+ } else if (*p == '{' || *p == '[') {
+ free(key);
+ char open = *p;
+ char close = (open == '{') ? '}' : ']';
+ int depth = 0;
+ int in_str = 0;
+ for (; *p; p++) {
+ if (in_str) {
+ if (*p == '\\' && p[1]) {
+ p++;
+ continue;
+ }
+ if (*p == '"')
+ in_str = 0;
+ continue;
+ }
+ if (*p == '"') {
+ in_str = 1;
+ continue;
+ }
+ if (*p == open)
+ depth++;
+ else if (*p == close) {
+ depth--;
+ if (depth == 0) {
+ p++;
+ break;
+ }
+ }
+ }
+ } else {
+ free(key);
+ while (*p && *p != ',' && *p != '}')
+ p++;
+ }
+ }
+ return 0;
+}
diff --git a/src/tui.c b/src/tui.c
new file mode 100644
index 0000000..c663d77
--- /dev/null
+++ b/src/tui.c
@@ -0,0 +1,2761 @@
+#define _GNU_SOURCE
+
+#include "cst_agent.h"
+#include "sashidown.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef _WIN32
+#include
+#include
+#else
+#include
+#endif
+
+/* Curses: ncursesw on Unix, PDCurses (wide) on Windows. */
+#if defined(CST_USE_PDCURSES) || defined(_WIN32)
+#ifndef PDC_WIDE
+#define PDC_WIDE 1
+#endif
+#ifndef NCURSES_MOUSE_VERSION
+#define NCURSES_MOUSE_VERSION 2
+#endif
+#include
+#ifndef BUTTON4_PRESSED
+#define BUTTON4_PRESSED 0
+#endif
+#ifndef BUTTON5_PRESSED
+#define BUTTON5_PRESSED 0
+#endif
+#ifndef KEY_RESIZE
+#define KEY_RESIZE 0632
+#endif
+/* MinGW often lacks wcwidth; simple East-Asian / ASCII heuristic. */
+static int cst_wcwidth(wchar_t wc) {
+ if (wc == 0)
+ return 0;
+ if (wc < 32 || (wc >= 0x7f && wc < 0xa0))
+ return -1;
+ if (wc >= 0x1100 &&
+ (wc <= 0x115f || wc == 0x2329 || wc == 0x232a ||
+ (wc >= 0x2e80 && wc <= 0xa4cf && wc != 0x303f) ||
+ (wc >= 0xac00 && wc <= 0xd7a3) || (wc >= 0xf900 && wc <= 0xfaff) ||
+ (wc >= 0xfe10 && wc <= 0xfe19) || (wc >= 0xfe30 && wc <= 0xfe6f) ||
+ (wc >= 0xff00 && wc <= 0xff60) || (wc >= 0xffe0 && wc <= 0xffe6) ||
+ (wc >= 0x20000 && wc <= 0x2fffd) || (wc >= 0x30000 && wc <= 0x3fffd)))
+ return 2;
+ return 1;
+}
+#define wcwidth cst_wcwidth
+#else
+#include
+#endif
+
+#ifdef _WIN32
+static void cst_usleep(unsigned usec) {
+ Sleep((usec + 999) / 1000);
+}
+static int cst_clock_gettime_mono(struct timespec *ts) {
+ static LARGE_INTEGER freq;
+ static int init;
+ LARGE_INTEGER c;
+ if (!init) {
+ QueryPerformanceFrequency(&freq);
+ init = 1;
+ }
+ QueryPerformanceCounter(&c);
+ ts->tv_sec = (time_t)(c.QuadPart / freq.QuadPart);
+ ts->tv_nsec =
+ (long)(((c.QuadPart % freq.QuadPart) * 1000000000ULL) / freq.QuadPart);
+ return 0;
+}
+#define usleep(us) cst_usleep((unsigned)(us))
+#define clock_gettime(clk, ts) cst_clock_gettime_mono(ts)
+#ifndef CLOCK_MONOTONIC
+#define CLOCK_MONOTONIC 1
+#endif
+#endif
+
+/*
+ * OpenCode-inspired C ncurses/PDCurses TUI:
+ * structured transcript blocks, thinking/tool cards, permissions, cancel.
+ */
+
+#define INPUT_CAP 4096
+#define SCROLL_SPEED 3
+#define REPLAY_MSGS 30
+#define MD_INIT (64 * 1024)
+#define ANSI_INIT (64 * 1024)
+#define TOOL_PREVIEW_LINES 3
+#define DIFF_FOLD_LINES 40
+#define BLOCKS_INIT 64
+#define MAX_RENDER_LINES 65536
+#define PROMPT_HIST_CAP 50
+#define QUEUE_CAP 3
+#define INPUT_MAX_ROWS 6
+
+/* Color pair indices */
+enum {
+ CP_HEADER = 1,
+ CP_STATUS,
+ CP_INPUT,
+ CP_PRIMARY, /* user / accents */
+ CP_ASSIST, /* cyan assistant */
+ CP_MUTED,
+ CP_SUCCESS,
+ CP_ERROR,
+ CP_TOOL,
+ CP_PANEL, /* transcript bg */
+ CP_SGR_GREEN,
+ CP_SGR_YELLOW,
+ CP_SGR_CYAN,
+ CP_SGR_MAGENTA,
+ /* OpenCode-style structured diff (bg distinguishes add/del/context) */
+ CP_DIFF_ADD,
+ CP_DIFF_DEL,
+ CP_DIFF_CTX
+};
+
+typedef enum {
+ BLK_USER = 0,
+ BLK_ASSISTANT,
+ BLK_THINK,
+ BLK_TOOL,
+ BLK_STATUS,
+ BLK_SYSTEM
+} block_kind_t;
+
+typedef struct {
+ block_kind_t kind;
+ char *title;
+ char *body; /* raw text / markdown / tool output */
+ char *render; /* ANSI for assistant; NULL → draw body */
+ int expanded;
+ char meta[256]; /* phase / timing / detail */
+ char tool_name[CTL_MAX_NAME];
+ char tool_id[CTL_MAX_ID];
+} block_t;
+
+typedef struct {
+ block_t *items;
+ int n;
+ int cap;
+} blocks_t;
+
+typedef struct {
+ cst_agent_t *agent;
+ WINDOW *header;
+ WINDOW *logw;
+ WINDOW *status;
+ WINDOW *input;
+ int rows, cols;
+ int input_rows; /* 1..INPUT_MAX_ROWS */
+
+ blocks_t hist; /* sealed blocks */
+ block_t live; /* streaming assistant (kind set when as_live) */
+ int as_live;
+ char *md_live;
+ size_t md_len;
+ size_t md_cap;
+
+ int log_scroll;
+ char inbuf[INPUT_CAP];
+ size_t in_len;
+ char status_msg[512];
+
+ int busy;
+ int details_all; /* /details: expand all tool bodies */
+ int awaiting_perm;
+ int think_idx; /* hist index of open think, or -1 */
+ int last_tool_idx;
+ int focus_think_idx;
+ struct timespec think_t0;
+ int think_timing; /* think_t0 valid */
+
+ /* Prompt history (newest at end). hist_browse=-1 means live buffer. */
+ char *prompt_hist[PROMPT_HIST_CAP];
+ int prompt_hist_n;
+ int hist_browse;
+
+ /* Busy-turn FIFO */
+ char *queue[QUEUE_CAP];
+ int queue_n;
+
+ /* Session-scoped always-allow (process lifetime). */
+ int always_shell;
+ int always_write; /* write_file + apply_patch */
+
+ /* Permission handshake (worker → main). */
+ volatile int perm_ask;
+ volatile int perm_ans; /* -1 waiting, 0 deny, 1 allow */
+ char perm_tool[CTL_MAX_NAME];
+ char perm_detail[512];
+
+ pthread_mutex_t mu;
+ volatile int dirty;
+ volatile int turn_done;
+ int turn_rc;
+ char turn_err[512];
+} tui_t;
+
+/* --- UTF-8 helpers -------------------------------------------------------- */
+
+static size_t utf8_prev_len(const char *s, size_t len) {
+ if (len == 0)
+ return 0;
+ size_t i = len - 1;
+ while (i > 0 && ((unsigned char)s[i] & 0xC0) == 0x80)
+ i--;
+ return len - i;
+}
+
+static int utf8_decode(const char *s, size_t avail, wchar_t *wc, size_t *eaten) {
+ if (avail == 0)
+ return -1;
+ mbstate_t st;
+ memset(&st, 0, sizeof(st));
+ size_t n = mbrtowc(wc, s, avail, &st);
+ if (n == (size_t)-1 || n == (size_t)-2 || n == 0) {
+ *wc = (unsigned char)s[0];
+ *eaten = 1;
+ return 0;
+ }
+ *eaten = n;
+ return 0;
+}
+
+static int utf8_width(wchar_t wc) {
+ int w = wcwidth(wc);
+ if (w < 0)
+ return 1;
+ return w;
+}
+
+static int utf8_cols(const char *s, size_t n) {
+ int cols = 0;
+ size_t i = 0;
+ while (i < n) {
+ wchar_t wc;
+ size_t eaten;
+ utf8_decode(s + i, n - i, &wc, &eaten);
+ cols += utf8_width(wc);
+ i += eaten;
+ }
+ return cols;
+}
+
+static size_t utf8_fit_cols(const char *s, size_t n, int max_cols) {
+ int cols = 0;
+ size_t i = 0;
+ while (i < n) {
+ wchar_t wc;
+ size_t eaten;
+ utf8_decode(s + i, n - i, &wc, &eaten);
+ int w = utf8_width(wc);
+ if (cols + w > max_cols)
+ break;
+ cols += w;
+ i += eaten;
+ }
+ return i;
+}
+
+static const char *utf8_tail_cols(const char *s, size_t n, int max_cols,
+ size_t *out_len) {
+ if (utf8_cols(s, n) <= max_cols) {
+ *out_len = n;
+ return s;
+ }
+ size_t i = 0;
+ while (i < n) {
+ wchar_t wc;
+ size_t eaten;
+ utf8_decode(s + i, n - i, &wc, &eaten);
+ i += eaten;
+ if (utf8_cols(s + i, n - i) <= max_cols) {
+ *out_len = n - i;
+ return s + i;
+ }
+ }
+ *out_len = 0;
+ return s + n;
+}
+
+static size_t ansi_skip(const char *s, size_t n) {
+ if (n < 2 || (unsigned char)s[0] != 0x1b)
+ return 0;
+ if (s[1] == '[') {
+ size_t i = 2;
+ while (i < n) {
+ unsigned char c = (unsigned char)s[i++];
+ if (c >= 0x40 && c <= 0x7e)
+ return i;
+ }
+ return 0;
+ }
+ return 2;
+}
+
+static char *xstrdup(const char *s) {
+ if (!s)
+ s = "";
+ size_t n = strlen(s);
+ char *p = malloc(n + 1);
+ if (!p)
+ return NULL;
+ memcpy(p, s, n + 1);
+ return p;
+}
+
+static char *xstrndup(const char *s, size_t n) {
+ char *p = malloc(n + 1);
+ if (!p)
+ return NULL;
+ memcpy(p, s, n);
+ p[n] = 0;
+ return p;
+}
+
+/* --- theme ---------------------------------------------------------------- */
+
+/* OpenCode dark panel — never inherit terminal default bg. */
+static void theme_init(void) {
+ if (!has_colors())
+ return;
+ start_color();
+ /* Do NOT call use_default_colors(): -1 would leak the terminal theme. */
+
+ short primary = COLOR_YELLOW;
+ short panel = COLOR_BLACK;
+ short muted = COLOR_CYAN;
+ short success = COLOR_GREEN;
+ short err = COLOR_RED;
+ short assist = COLOR_CYAN;
+ short text = COLOR_WHITE;
+
+ if (COLORS >= 256) {
+ /* Prefer xterm-256 slots so we don't bleach the 8 ANSI base colors.
+ * panel #121212→233, primary #fab283→216, muted #808080→244 */
+ primary = 216;
+ panel = 233; /* #121212 — darker than 234 to avoid washed look */
+ muted = 244;
+ success = 114;
+ err = 167;
+ assist = 74; /* soft cyan, not neon */
+ text = 250; /* #bcbcbc — not near-white */
+ if (can_change_color()) {
+ /* Tune those slots toward exact OpenCode hex */
+ init_color(216, 980, 698, 514); /* #fab283 */
+ init_color(233, 70, 70, 70); /* #121212 */
+ init_color(244, 502, 502, 502); /* #808080 */
+ init_color(250, 720, 720, 720); /* body text */
+ init_color(114, 400, 750, 450);
+ init_color(167, 850, 320, 320);
+ init_color(74, 350, 700, 750);
+ }
+ } else if (can_change_color() && COLORS >= 8) {
+ init_color(COLOR_BLACK, 70, 70, 70); /* panel #121212 */
+ init_color(COLOR_YELLOW, 980, 698, 514); /* primary */
+ init_color(COLOR_CYAN, 502, 502, 502); /* muted (reuse cyan slot) */
+ init_color(COLOR_WHITE, 720, 720, 720); /* body text */
+ init_color(COLOR_GREEN, 400, 750, 450);
+ init_color(COLOR_RED, 850, 320, 320);
+ init_color(COLOR_BLUE, 350, 700, 750); /* assist */
+ panel = COLOR_BLACK;
+ primary = COLOR_YELLOW;
+ muted = COLOR_CYAN;
+ text = COLOR_WHITE;
+ success = COLOR_GREEN;
+ err = COLOR_RED;
+ assist = COLOR_BLUE;
+ }
+
+ /* Header is dark too — primary only as text accent (not a bright bar). */
+ init_pair(CP_HEADER, primary, panel);
+ init_pair(CP_STATUS, muted, panel);
+ init_pair(CP_INPUT, text, panel);
+ init_pair(CP_PRIMARY, primary, panel);
+ init_pair(CP_ASSIST, assist, panel);
+ init_pair(CP_MUTED, muted, panel);
+ init_pair(CP_SUCCESS, success, panel);
+ init_pair(CP_ERROR, err, panel);
+ init_pair(CP_TOOL, primary, panel);
+ init_pair(CP_PANEL, text, panel);
+ init_pair(CP_SGR_GREEN, success, panel);
+ init_pair(CP_SGR_YELLOW, primary, panel);
+ init_pair(CP_SGR_CYAN, assist, panel);
+ if (COLORS >= 256)
+ init_pair(CP_SGR_MAGENTA, 176, panel);
+ else
+ init_pair(CP_SGR_MAGENTA, COLOR_MAGENTA, panel);
+
+ /* Diff rows: high-contrast xterm-256 palette only.
+ * Do NOT init_color() cube indices 16–21 — many terminals ignore it and leave
+ * default blues, which made add/del rows unreadable (dark-on-blue). */
+ if (COLORS >= 256) {
+ init_pair(CP_DIFF_ADD, 157, 22); /* #afff87 on #005f00 */
+ init_pair(CP_DIFF_DEL, 203, 52); /* #ff5f5f on #5f0000 */
+ init_pair(CP_DIFF_CTX, 252, panel); /* bright gray on panel */
+ } else {
+ init_pair(CP_DIFF_ADD, COLOR_GREEN, COLOR_BLACK);
+ init_pair(CP_DIFF_DEL, COLOR_RED, COLOR_BLACK);
+ init_pair(CP_DIFF_CTX, COLOR_WHITE, COLOR_BLACK);
+ }
+}
+
+static void theme_paint_root(void) {
+ if (!has_colors())
+ return;
+ wbkgd(stdscr, COLOR_PAIR(CP_PANEL));
+ werase(stdscr);
+ wnoutrefresh(stdscr);
+}
+
+/* --- blocks --------------------------------------------------------------- */
+
+static void block_clear(block_t *b) {
+ if (!b)
+ return;
+ free(b->title);
+ free(b->body);
+ free(b->render);
+ memset(b, 0, sizeof(*b));
+}
+
+static void blocks_init(blocks_t *bs) {
+ bs->items = calloc((size_t)BLOCKS_INIT, sizeof(block_t));
+ bs->n = 0;
+ bs->cap = bs->items ? BLOCKS_INIT : 0;
+}
+
+static void blocks_free(blocks_t *bs) {
+ if (!bs)
+ return;
+ for (int i = 0; i < bs->n; i++)
+ block_clear(&bs->items[i]);
+ free(bs->items);
+ bs->items = NULL;
+ bs->n = bs->cap = 0;
+}
+
+static void blocks_clear(blocks_t *bs) {
+ for (int i = 0; i < bs->n; i++)
+ block_clear(&bs->items[i]);
+ bs->n = 0;
+}
+
+static block_t *blocks_push(blocks_t *bs) {
+ if (!bs->items)
+ return NULL;
+ if (bs->n >= bs->cap) {
+ int nc = bs->cap * 2;
+ block_t *ni = realloc(bs->items, (size_t)nc * sizeof(block_t));
+ if (!ni)
+ return NULL;
+ memset(ni + bs->cap, 0, (size_t)(nc - bs->cap) * sizeof(block_t));
+ bs->items = ni;
+ bs->cap = nc;
+ }
+ block_t *b = &bs->items[bs->n++];
+ memset(b, 0, sizeof(*b));
+ return b;
+}
+
+static void block_set_title(block_t *b, const char *t) {
+ free(b->title);
+ b->title = xstrdup(t ? t : "");
+}
+
+static void block_set_body(block_t *b, const char *s, size_t n) {
+ free(b->body);
+ b->body = n ? xstrndup(s, n) : xstrdup("");
+}
+
+static void block_append_body(block_t *b, const char *s, size_t n) {
+ if (!s || !n)
+ return;
+ size_t old = b->body ? strlen(b->body) : 0;
+ char *nb = realloc(b->body, old + n + 1);
+ if (!nb)
+ return;
+ memcpy(nb + old, s, n);
+ nb[old + n] = 0;
+ b->body = nb;
+}
+
+/* Markdown → ANSI via Sashidown. */
+static int md_to_ansi_dyn(const char *md, size_t md_len, char **outp,
+ size_t *out_len) {
+ if (!md || !outp)
+ return -1;
+ if (md_len == 0) {
+ *outp = NULL;
+ if (out_len)
+ *out_len = 0;
+ return 0;
+ }
+ size_t cap = ANSI_INIT;
+ char *out = malloc(cap);
+ if (!out)
+ return -1;
+ for (;;) {
+ size_t n = 0;
+ int rc = sashidown_convert(md, md_len, out, cap, &n, SASHIDOWN_FMT_ANSI);
+ if (rc == 0) {
+ *outp = out;
+ if (out_len)
+ *out_len = n;
+ return 0;
+ }
+ size_t nc = cap * 2;
+ if (nc < md_len * 4)
+ nc = md_len * 4;
+ if (nc > 16 * 1024 * 1024) {
+ free(out);
+ out = malloc(md_len + 1);
+ if (!out)
+ return -1;
+ memcpy(out, md, md_len);
+ out[md_len] = 0;
+ *outp = out;
+ if (out_len)
+ *out_len = md_len;
+ return 0;
+ }
+ char *nb = realloc(out, nc);
+ if (!nb) {
+ free(out);
+ return -1;
+ }
+ out = nb;
+ cap = nc;
+ }
+}
+
+static void block_render_assistant(block_t *b) {
+ free(b->render);
+ b->render = NULL;
+ if (!b->body || !b->body[0])
+ return;
+ char *ansi = NULL;
+ size_t n = 0;
+ if (md_to_ansi_dyn(b->body, strlen(b->body), &ansi, &n) == 0 && ansi) {
+ b->render = ansi;
+ }
+}
+
+static const char *tool_prefix(const char *name) {
+ if (!name)
+ return "•";
+ if (strcmp(name, "run_shell") == 0)
+ return "$";
+ if (strcmp(name, "write_file") == 0 || strcmp(name, "apply_patch") == 0)
+ return "←";
+ if (strcmp(name, "read_file") == 0)
+ return "✱";
+ return "•";
+}
+
+static void tool_arg_summary(const ctl_tool_call_t *call, char *out,
+ size_t out_sz) {
+ out[0] = 0;
+ if (!call || out_sz == 0)
+ return;
+ const char *prefer = NULL;
+ if (strcmp(call->name, "run_shell") == 0)
+ prefer = "cmd";
+ else if (strcmp(call->name, "read_file") == 0 ||
+ strcmp(call->name, "write_file") == 0)
+ prefer = "path";
+ else if (strcmp(call->name, "apply_patch") == 0)
+ prefer = "patch";
+
+ for (int i = 0; i < call->nargs; i++) {
+ if (prefer && strcmp(call->args[i].key, prefer) != 0)
+ continue;
+ const char *v = call->args[i].value ? call->args[i].value : "";
+ size_t len = call->args[i].value_len;
+ if (len == 0 && v[0])
+ len = strlen(v);
+ if (len > 80)
+ len = 80;
+ snprintf(out, out_sz, "%.*s%s", (int)len, v, len >= 80 ? "…" : "");
+ return;
+ }
+ if (call->nargs > 0 && call->args[0].value) {
+ size_t len = call->args[0].value_len;
+ if (len == 0)
+ len = strlen(call->args[0].value);
+ if (len > 80)
+ len = 80;
+ snprintf(out, out_sz, "%.*s%s", (int)len, call->args[0].value,
+ len >= 80 ? "…" : "");
+ }
+}
+
+static void tui_lock(tui_t *t) { pthread_mutex_lock(&t->mu); }
+static void tui_unlock(tui_t *t) { pthread_mutex_unlock(&t->mu); }
+static void tui_mark_dirty(tui_t *t) { t->dirty = 1; }
+
+static void set_status_locked(tui_t *t, const char *msg) {
+ snprintf(t->status_msg, sizeof(t->status_msg), "%s", msg ? msg : "");
+ tui_mark_dirty(t);
+}
+
+static void set_status(tui_t *t, const char *msg) {
+ tui_lock(t);
+ set_status_locked(t, msg);
+ tui_unlock(t);
+}
+
+static void short_session_id(tui_t *t, char *out, size_t out_sz) {
+ out[0] = 0;
+ const cst_agent_config_t *cfg = cst_agent_config(t->agent);
+ if (!cfg)
+ return;
+ char id[128] = {0};
+ if (cst_agent_store_get_current(cfg->workspace_root, id, sizeof(id)) == 0 &&
+ id[0])
+ snprintf(out, out_sz, "%.8s", id);
+}
+
+static void status_idle(tui_t *t) {
+ char sid[16] = {0};
+ short_session_id(t, sid, sizeof(sid));
+ if (t->queue_n > 0) {
+ snprintf(t->status_msg, sizeof(t->status_msg),
+ "ready · queued %d · empty Enter = next · /help%s%s", t->queue_n,
+ sid[0] ? " · " : "", sid[0] ? sid : "");
+ } else if (sid[0]) {
+ snprintf(t->status_msg, sizeof(t->status_msg),
+ "ready · Esc cancel · Ctrl+N newline · /help · session %s", sid);
+ } else {
+ snprintf(t->status_msg, sizeof(t->status_msg),
+ "ready · Esc cancel · Ctrl+N newline · /help");
+ }
+}
+
+/* --- live assistant ------------------------------------------------------- */
+
+static void live_end_cleanup(tui_t *t) {
+ block_clear(&t->live);
+ free(t->md_live);
+ t->md_live = NULL;
+ t->md_len = 0;
+ t->md_cap = 0;
+ t->as_live = 0;
+}
+
+static void live_begin(tui_t *t) {
+ live_end_cleanup(t);
+ t->md_cap = MD_INIT;
+ t->md_live = calloc(1, t->md_cap);
+ t->md_len = 0;
+ t->live.kind = BLK_ASSISTANT;
+ block_set_title(&t->live, "Assistant");
+ t->live.expanded = 1;
+ t->as_live = 1;
+}
+
+static void live_feed(tui_t *t, const char *text, size_t len) {
+ if (!t->as_live || !text || !len)
+ return;
+ if (t->md_len + len + 1 > t->md_cap) {
+ size_t nc = t->md_cap * 2;
+ while (nc < t->md_len + len + 1)
+ nc *= 2;
+ char *nb = realloc(t->md_live, nc);
+ if (!nb)
+ return;
+ t->md_live = nb;
+ t->md_cap = nc;
+ }
+ memcpy(t->md_live + t->md_len, text, len);
+ t->md_len += len;
+ t->md_live[t->md_len] = 0;
+ /* During stream show raw markdown; convert on seal. */
+ free(t->live.body);
+ t->live.body = xstrndup(t->md_live, t->md_len);
+ free(t->live.render);
+ t->live.render = NULL;
+}
+
+static void live_seal(tui_t *t) {
+ if (!t->as_live)
+ return;
+ block_t *b = blocks_push(&t->hist);
+ if (b) {
+ b->kind = BLK_ASSISTANT;
+ block_set_title(b, "Assistant");
+ if (t->md_len)
+ block_set_body(b, t->md_live, t->md_len);
+ else if (t->live.body)
+ block_set_body(b, t->live.body, strlen(t->live.body));
+ b->expanded = 1;
+ block_render_assistant(b);
+ }
+ live_end_cleanup(t);
+}
+
+static void collapse_open_think(tui_t *t) {
+ if (t->think_idx < 0 || t->think_idx >= t->hist.n)
+ return;
+ block_t *th = &t->hist.items[t->think_idx];
+ if (th->kind != BLK_THINK)
+ return;
+ th->expanded = 0;
+ char title[64];
+ if (t->think_timing) {
+ struct timespec now;
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ double sec = (double)(now.tv_sec - t->think_t0.tv_sec) +
+ (double)(now.tv_nsec - t->think_t0.tv_nsec) / 1e9;
+ if (sec < 0)
+ sec = 0;
+ if (sec < 1.0)
+ snprintf(title, sizeof(title), "Thought · %.1fs", sec);
+ else
+ snprintf(title, sizeof(title), "Thought · %.1fs", sec);
+ t->think_timing = 0;
+ } else {
+ snprintf(title, sizeof(title), "Thought");
+ }
+ block_set_title(th, title);
+ t->think_idx = -1;
+}
+
+static int find_tool_block(tui_t *t, const char *id) {
+ if (!id || !id[0])
+ return -1;
+ for (int i = t->hist.n - 1; i >= 0; i--) {
+ block_t *b = &t->hist.items[i];
+ if (b->kind == BLK_TOOL && strcmp(b->tool_id, id) == 0)
+ return i;
+ }
+ return -1;
+}
+
+static void attach_tool_output_from_session(tui_t *t, block_t *b) {
+ if (!b || b->kind != BLK_TOOL || !b->tool_id[0])
+ return;
+ cst_session_t *s = cst_agent_session(t->agent);
+ if (!s)
+ return;
+ int n = cst_session_count(s);
+ for (int i = n - 1; i >= 0; i--) {
+ const cst_message_t *m = cst_session_get(s, i);
+ if (!m || m->role != CST_ROLE_TOOL)
+ continue;
+ if (strcmp(m->tool_call_id, b->tool_id) != 0)
+ continue;
+ if (m->content)
+ block_set_body(b, m->content, strlen(m->content));
+ return;
+ }
+}
+
+/* --- drawing: SGR --------------------------------------------------------- */
+
+typedef struct {
+ int attrs;
+ int pair;
+} sty_t;
+
+static void sgr_apply(sty_t *st, int code) {
+ switch (code) {
+ case 0:
+ st->attrs = 0;
+ st->pair = 0;
+ break;
+ case 1:
+ st->attrs |= A_BOLD;
+ break;
+ case 2:
+ st->attrs |= A_DIM;
+ break;
+ case 3:
+#ifdef A_ITALIC
+ st->attrs |= A_ITALIC;
+#endif
+ break;
+ case 4:
+ st->attrs |= A_UNDERLINE;
+ break;
+ case 7:
+ st->attrs |= A_REVERSE;
+ break;
+ case 22:
+ st->attrs &= ~(A_BOLD | A_DIM);
+ break;
+ case 23:
+#ifdef A_ITALIC
+ st->attrs &= ~A_ITALIC;
+#endif
+ break;
+ case 24:
+ st->attrs &= ~A_UNDERLINE;
+ break;
+ case 27:
+ st->attrs &= ~A_REVERSE;
+ break;
+ case 32:
+ case 92:
+ st->pair = CP_SGR_GREEN;
+ break;
+ case 33:
+ case 93:
+ st->pair = CP_SGR_YELLOW;
+ break;
+ case 36:
+ case 96:
+ st->pair = CP_SGR_CYAN;
+ break;
+ case 35:
+ case 95:
+ st->pair = CP_SGR_MAGENTA;
+ break;
+ case 39:
+ st->pair = 0;
+ break;
+ default:
+ break;
+ }
+}
+
+static size_t sgr_parse(const char *s, size_t n, sty_t *st) {
+ if (n < 3 || (unsigned char)s[0] != 0x1b || s[1] != '[')
+ return 0;
+ size_t i = 2;
+ int val = 0, have = 0;
+ while (i < n) {
+ unsigned char c = (unsigned char)s[i++];
+ if (c >= '0' && c <= '9') {
+ val = val * 10 + (c - '0');
+ have = 1;
+ } else if (c == ';') {
+ sgr_apply(st, have ? val : 0);
+ val = 0;
+ have = 0;
+ } else if (c == 'm') {
+ sgr_apply(st, have ? val : 0);
+ return i;
+ } else {
+ return 0;
+ }
+ }
+ return 0;
+}
+
+static int put_wc(WINDOW *win, int y, int col, wchar_t wc, int attrs) {
+ cchar_t cc;
+ wchar_t wstr[2] = {wc, 0};
+ short pair = (short)PAIR_NUMBER(attrs);
+ attr_t a = (attr_t)(attrs & ~(attr_t)A_COLOR);
+ if (setcchar(&cc, wstr, a, pair, NULL) == ERR) {
+ if (wc < 128)
+ mvwaddch(win, y, col, (chtype)wc | (chtype)attrs);
+ else
+ mvwaddstr(win, y, col, "?");
+ return 1;
+ }
+ mvwadd_wch(win, y, col, &cc);
+ return utf8_width(wc);
+}
+
+/* Flatten blocks into display lines (plain text with optional ANSI in body). */
+typedef struct {
+ char *text; /* owned line content (may include ANSI) */
+ int pair; /* base color pair for whole line (0 = default) */
+ int bold;
+ int dim;
+ int indent; /* leading columns already accounted in text */
+} dline_t;
+
+typedef struct {
+ dline_t *lines;
+ int n;
+ int cap;
+} dlines_t;
+
+static void dlines_free(dlines_t *dl) {
+ if (!dl)
+ return;
+ for (int i = 0; i < dl->n; i++)
+ free(dl->lines[i].text);
+ free(dl->lines);
+ dl->lines = NULL;
+ dl->n = dl->cap = 0;
+}
+
+static int dlines_push(dlines_t *dl, const char *text, int pair, int bold,
+ int dim) {
+ if (dl->n >= dl->cap) {
+ int nc = dl->cap ? dl->cap * 2 : 256;
+ dline_t *ni = realloc(dl->lines, (size_t)nc * sizeof(dline_t));
+ if (!ni)
+ return -1;
+ dl->lines = ni;
+ dl->cap = nc;
+ }
+ dline_t *L = &dl->lines[dl->n++];
+ L->text = xstrdup(text ? text : "");
+ L->pair = pair;
+ L->bold = bold;
+ L->dim = dim;
+ L->indent = 0;
+ return 0;
+}
+
+static void push_wrapped(dlines_t *dl, const char *prefix, const char *body,
+ int width, int pair, int bold, int dim, int ansi) {
+ (void)ansi;
+ char line[4096];
+ int pref_cols = prefix ? utf8_cols(prefix, strlen(prefix)) : 0;
+ int body_w = width - pref_cols;
+ if (body_w < 8)
+ body_w = 8;
+
+ if (!body || !body[0]) {
+ snprintf(line, sizeof(line), "%s", prefix ? prefix : "");
+ dlines_push(dl, line, pair, bold, dim);
+ return;
+ }
+
+ /* Walk body; for ANSI text, wrap on visible columns skipping SGR. */
+ size_t n = strlen(body);
+ size_t i = 0;
+ int first = 1;
+ while (i < n && dl->n < MAX_RENDER_LINES) {
+ size_t start = i;
+ int col = 0;
+ while (i < n) {
+ size_t sg = 0;
+ if ((unsigned char)body[i] == 0x1b) {
+ sty_t dummy = {0};
+ sg = sgr_parse(body + i, n - i, &dummy);
+ if (!sg)
+ sg = ansi_skip(body + i, n - i);
+ if (sg) {
+ i += sg;
+ continue;
+ }
+ }
+ if (body[i] == '\n') {
+ i++;
+ break;
+ }
+ wchar_t wc;
+ size_t eaten;
+ utf8_decode(body + i, n - i, &wc, &eaten);
+ int cw = utf8_width(wc);
+ if (col + cw > body_w && col > 0)
+ break;
+ col += cw;
+ i += eaten;
+ if (col >= body_w)
+ break;
+ }
+ size_t chunk = i - start;
+ /* trim trailing newline already consumed */
+ if (chunk > 0 && body[start + chunk - 1] == '\n')
+ chunk--;
+ if (first && prefix) {
+ if (chunk + strlen(prefix) + 1 < sizeof(line)) {
+ memcpy(line, prefix, strlen(prefix));
+ memcpy(line + strlen(prefix), body + start, chunk);
+ line[strlen(prefix) + chunk] = 0;
+ } else {
+ snprintf(line, sizeof(line), "%s", prefix);
+ }
+ first = 0;
+ } else {
+ /* continuation indent matching prefix visual width */
+ int pad = pref_cols;
+ if (pad < 0)
+ pad = 0;
+ if (pad > 40)
+ pad = 40;
+ memset(line, ' ', (size_t)pad);
+ if (chunk + (size_t)pad + 1 < sizeof(line)) {
+ memcpy(line + pad, body + start, chunk);
+ line[pad + chunk] = 0;
+ } else {
+ line[pad] = 0;
+ }
+ }
+ dlines_push(dl, line, pair, bold, dim);
+ if (i < n && body[i - 1] != '\n' && col >= body_w) {
+ /* soft wrap — already advanced */
+ }
+ }
+}
+
+static void push_body_preview(dlines_t *dl, const char *body, int width,
+ int pair, int expanded, int max_lines) {
+ if (!body || !body[0])
+ return;
+ if (expanded) {
+ push_wrapped(dl, " ", body, width, pair, 0, 1, 0);
+ return;
+ }
+ const char *p = body;
+ int shown = 0;
+ while (*p && shown < max_lines) {
+ const char *nl = strchr(p, '\n');
+ size_t len = nl ? (size_t)(nl - p) : strlen(p);
+ char tmp[1024];
+ if (len >= sizeof(tmp))
+ len = sizeof(tmp) - 1;
+ memcpy(tmp, p, len);
+ tmp[len] = 0;
+ push_wrapped(dl, " ", tmp, width, pair, 0, 1, 0);
+ shown++;
+ if (!nl)
+ break;
+ p = nl + 1;
+ }
+ if (*p)
+ dlines_push(dl, " …", pair, 0, 1);
+}
+
+static int body_looks_like_diff(const char *body) {
+ if (!body || !body[0])
+ return 0;
+ return strstr(body, "\n--- ") || strstr(body, "\n+++ ") ||
+ strstr(body, "\n@@ ") || strstr(body, "\n@@-") ||
+ strncmp(body, "--- ", 4) == 0 || strncmp(body, "diff ", 5) == 0 ||
+ strstr(body, "\n--- a/") || strstr(body, "\n+++ b/");
+}
+
+static void diff_extract_path(const char *body, const char *fallback,
+ char *out, size_t out_sz) {
+ if (!out || out_sz == 0)
+ return;
+ out[0] = 0;
+ if (body) {
+ const char *p = body;
+ while (*p) {
+ const char *nl = strchr(p, '\n');
+ size_t len = nl ? (size_t)(nl - p) : strlen(p);
+ if (len > 6 && strncmp(p, "wrote ", 6) == 0) {
+ const char *s = p + 6;
+ size_t n = len - 6;
+ /* trim " (N bytes)" */
+ const char *sp = memchr(s, ' ', n);
+ if (sp)
+ n = (size_t)(sp - s);
+ if (n >= out_sz)
+ n = out_sz - 1;
+ memcpy(out, s, n);
+ out[n] = 0;
+ return;
+ }
+ if (len > 6 && strncmp(p, "+++ b/", 6) == 0) {
+ size_t n = len - 6;
+ if (n >= out_sz)
+ n = out_sz - 1;
+ memcpy(out, p + 6, n);
+ out[n] = 0;
+ return;
+ }
+ if (len > 6 && strncmp(p, "--- a/", 6) == 0) {
+ size_t n = len - 6;
+ if (n >= out_sz)
+ n = out_sz - 1;
+ memcpy(out, p + 6, n);
+ out[n] = 0;
+ /* keep scanning for +++ / wrote */
+ }
+ if (!nl)
+ break;
+ p = nl + 1;
+ }
+ }
+ if (!out[0] && fallback && fallback[0]) {
+ snprintf(out, out_sz, "%s", fallback);
+ }
+}
+
+static int parse_hunk_at(const char *line, int *old_ln, int *new_ln) {
+ /* @@ -old[,n] +new[,n] @@ */
+ if (line[0] != '@' || line[1] != '@')
+ return 0;
+ const char *p = line;
+ while (*p && *p != '-')
+ p++;
+ if (*p != '-')
+ return 0;
+ p++;
+ int o = 0, n = 0;
+ if (sscanf(p, "%d", &o) != 1)
+ return 0;
+ while (*p && *p != '+')
+ p++;
+ if (*p != '+')
+ return 0;
+ p++;
+ if (sscanf(p, "%d", &n) != 1)
+ return 0;
+ if (old_ln)
+ *old_ln = o;
+ if (new_ln)
+ *new_ln = n;
+ return 1;
+}
+
+typedef struct {
+ int additions;
+ int deletions;
+ int content_lines;
+} diff_stats_t;
+
+static void diff_count_stats(const char *body, diff_stats_t *st) {
+ memset(st, 0, sizeof(*st));
+ if (!body)
+ return;
+ const char *p = body;
+ while (*p) {
+ const char *nl = strchr(p, '\n');
+ size_t len = nl ? (size_t)(nl - p) : strlen(p);
+ if (len == 0) {
+ /* empty */
+ } else if (strncmp(p, "---", 3) == 0 || strncmp(p, "+++", 3) == 0 ||
+ strncmp(p, "diff ", 5) == 0 || strncmp(p, "index ", 6) == 0 ||
+ strncmp(p, "@@", 2) == 0 || strncmp(p, "wrote ", 6) == 0 ||
+ (p[0] == '[' && strstr(p, "tool "))) {
+ /* skip meta */
+ } else if (p[0] == '+') {
+ st->additions++;
+ st->content_lines++;
+ } else if (p[0] == '-') {
+ st->deletions++;
+ st->content_lines++;
+ } else if (p[0] == ' ' || p[0] == '\\') {
+ st->content_lines++;
+ }
+ if (!nl)
+ break;
+ p = nl + 1;
+ }
+}
+
+/* OpenCode-style: strip ---/+++/@@, line numbers, add/del backgrounds. */
+static void push_structured_diff(dlines_t *dl, const char *body, int width,
+ int unlimited, int fold_limit) {
+ if (!body || !body[0])
+ return;
+ int old_ln = 0, new_ln = 0;
+ int shown = 0;
+ int total_content = 0;
+ /* First pass: count content lines for fold message */
+ {
+ diff_stats_t st;
+ diff_count_stats(body, &st);
+ total_content = st.content_lines;
+ }
+ int limit = unlimited ? 1000000 : fold_limit;
+ if (limit < 1)
+ limit = 1;
+
+ const char *p = body;
+ while (*p) {
+ const char *nl = strchr(p, '\n');
+ size_t len = nl ? (size_t)(nl - p) : strlen(p);
+ char raw[1024];
+ if (len >= sizeof(raw))
+ len = sizeof(raw) - 1;
+ memcpy(raw, p, len);
+ raw[len] = 0;
+
+ int skip = 0;
+ if (raw[0] == 0)
+ skip = 1;
+ else if (strncmp(raw, "wrote ", 6) == 0)
+ skip = 1;
+ else if (raw[0] == '[' && strstr(raw, "tool "))
+ skip = 1;
+ else if (strncmp(raw, "---", 3) == 0 || strncmp(raw, "+++", 3) == 0 ||
+ strncmp(raw, "diff ", 5) == 0 || strncmp(raw, "index ", 6) == 0)
+ skip = 1;
+ else if (strncmp(raw, "@@", 2) == 0) {
+ parse_hunk_at(raw, &old_ln, &new_ln);
+ skip = 1;
+ } else if (raw[0] == '\\') {
+ /* "\ No newline at end of file" */
+ skip = 1;
+ }
+
+ if (!skip && (raw[0] == '+' || raw[0] == '-' || raw[0] == ' ')) {
+ if (shown >= limit) {
+ /* keep counting via total_content; stop emitting */
+ break;
+ }
+ char kind = raw[0];
+ const char *content = raw + 1;
+ int lnum = 0;
+ char mark = ' ';
+ int pair = CP_DIFF_CTX;
+ if (kind == '+') {
+ lnum = new_ln++;
+ mark = '+';
+ pair = CP_DIFF_ADD;
+ } else if (kind == '-') {
+ lnum = old_ln++;
+ mark = '-';
+ pair = CP_DIFF_DEL;
+ } else {
+ lnum = new_ln;
+ old_ln++;
+ new_ln++;
+ mark = ' ';
+ pair = CP_DIFF_CTX;
+ }
+
+ char line[1200];
+ /* │ 12+│ content — left border + gutter + body */
+ int body_w = width - 8;
+ if (body_w < 8)
+ body_w = 8;
+ char clipped[1024];
+ size_t clen = strlen(content);
+ /* column-clip roughly by bytes for MVP */
+ if ((int)clen > body_w) {
+ int keep = body_w > 3 ? body_w - 3 : body_w;
+ memcpy(clipped, content, (size_t)keep);
+ if (body_w > 3)
+ memcpy(clipped + keep, "...", 4);
+ else
+ clipped[keep] = 0;
+ content = clipped;
+ }
+ snprintf(line, sizeof(line), "│%4d%c│ %s", lnum, mark, content);
+ /* Pad to width so row background fills like OpenCode. */
+ {
+ int cols = utf8_cols(line, strlen(line));
+ size_t llen = strlen(line);
+ while (cols < width && llen + 1 < sizeof(line)) {
+ line[llen++] = ' ';
+ line[llen] = 0;
+ cols++;
+ }
+ }
+ dlines_push(dl, line, pair, kind != ' ', 0);
+ shown++;
+ }
+
+ if (!nl)
+ break;
+ p = nl + 1;
+ }
+
+ if (!unlimited && total_content > shown) {
+ char more[96];
+ snprintf(more, sizeof(more), "│ … %d more lines · /details",
+ total_content - shown);
+ dlines_push(dl, more, CP_MUTED, 0, 1);
+ }
+}
+
+static void push_diff_or_body(dlines_t *dl, const char *body, int width,
+ int default_pair, int expanded, int max_lines,
+ int details_all) {
+ if (!body || !body[0])
+ return;
+ if (!body_looks_like_diff(body)) {
+ push_body_preview(dl, body, width, default_pair, expanded || details_all,
+ max_lines);
+ return;
+ }
+ /* Auto-expanded write cards still fold at DIFF_FOLD_LINES unless /details. */
+ int unlimited = details_all;
+ int fold = expanded ? DIFF_FOLD_LINES : max_lines;
+ if (!expanded && !details_all)
+ fold = 0; /* title-only when collapsed */
+ if (fold == 0 && !unlimited)
+ return;
+ push_structured_diff(dl, body, width, unlimited, fold);
+}
+
+static void block_to_lines(const block_t *b, int width, int details_all,
+ dlines_t *dl) {
+ char prefix[128];
+ char head[512];
+
+ switch (b->kind) {
+ case BLK_USER:
+ snprintf(prefix, sizeof(prefix), "┃ ");
+ dlines_push(dl, "┃ You", CP_PRIMARY, 1, 0);
+ push_wrapped(dl, prefix, b->body ? b->body : "", width, CP_PRIMARY, 0, 0,
+ 0);
+ dlines_push(dl, "", 0, 0, 0);
+ break;
+
+ case BLK_ASSISTANT: {
+ dlines_push(dl, "┃ Assistant", CP_ASSIST, 1, 0);
+ const char *src = b->render ? b->render : (b->body ? b->body : "");
+ push_wrapped(dl, "┃ ", src, width, CP_ASSIST, 0, 0, 1);
+ dlines_push(dl, "", 0, 0, 0);
+ break;
+ }
+
+ case BLK_THINK:
+ if (b->expanded) {
+ snprintf(head, sizeof(head), "▼ %s", b->title ? b->title : "Thought");
+ dlines_push(dl, head, CP_MUTED, 0, 1);
+ push_wrapped(dl, " ", b->body ? b->body : "", width, CP_MUTED, 0, 1, 0);
+ } else {
+ /* One-liner: title + truncated body hint */
+ const char *body = b->body ? b->body : "";
+ size_t bl = strlen(body);
+ while (bl > 0 && (body[bl - 1] == '\n' || body[bl - 1] == ' '))
+ bl--;
+ char one[200];
+ size_t take = bl > 60 ? 60 : bl;
+ /* skip to first non-space for preview */
+ size_t off = 0;
+ while (off < take && (body[off] == ' ' || body[off] == '\n'))
+ off++;
+ snprintf(one, sizeof(one), "▶ %s %.*s%s",
+ b->title ? b->title : "Thought", (int)(take > off ? take - off : 0),
+ body + off, bl > 60 ? "…" : "");
+ dlines_push(dl, one, CP_MUTED, 0, 1);
+ }
+ break;
+
+ case BLK_TOOL: {
+ const char *px = tool_prefix(b->tool_name);
+ int exp = details_all || b->expanded;
+ int denied = (strncmp(b->meta, "denied", 6) == 0);
+ int is_write = (strcmp(b->tool_name, "write_file") == 0);
+ int is_patch = (strcmp(b->tool_name, "apply_patch") == 0);
+ int is_file_edit = is_write || is_patch;
+ int done = (strncmp(b->meta, "exit=", 5) == 0) ||
+ (strcmp(b->meta, "done") == 0) ||
+ (b->body && body_looks_like_diff(b->body));
+
+ if (is_file_edit && !denied) {
+ char path[256];
+ diff_extract_path(b->body, b->title, path, sizeof(path));
+ if (!path[0] && b->title && b->title[0])
+ snprintf(path, sizeof(path), "%s", b->title);
+ if (!path[0])
+ snprintf(path, sizeof(path), "file");
+
+ diff_stats_t st = {0};
+ if (b->body)
+ diff_count_stats(b->body, &st);
+
+ char head[512];
+ if (done) {
+ /* OpenCode: ← Wrote path / ← Edit path +N -M */
+ snprintf(head, sizeof(head),
+ "│ ← %s %s \033[32m+%d\033[0m \033[31m-%d\033[0m",
+ is_write ? "Wrote" : "Edit", path, st.additions, st.deletions);
+ } else {
+ snprintf(head, sizeof(head), "│ ← %s %s %s",
+ is_write ? "Write" : "Edit", path,
+ b->meta[0] ? b->meta : "…");
+ }
+ dlines_push(dl, head, CP_MUTED, 0, 0);
+ if (done || exp)
+ push_diff_or_body(dl, b->body, width, CP_MUTED, b->expanded,
+ TOOL_PREVIEW_LINES, details_all);
+ } else {
+ char status_short[96];
+ status_short[0] = 0;
+ if (denied) {
+ snprintf(status_short, sizeof(status_short), "denied");
+ } else if (strncmp(b->meta, "exit=", 5) == 0) {
+ snprintf(status_short, sizeof(status_short), "done %.80s", b->meta);
+ } else if (b->meta[0]) {
+ snprintf(status_short, sizeof(status_short), "%.80s", b->meta);
+ }
+ snprintf(head, sizeof(head), "%s %s %s%s%s", px,
+ b->tool_name[0] ? b->tool_name : "tool",
+ b->title ? b->title : "",
+ status_short[0] ? " " : "", status_short);
+ dlines_push(dl, head, denied ? CP_ERROR : CP_TOOL, 1, 0);
+ push_diff_or_body(dl, b->body, width, denied ? CP_ERROR : CP_MUTED, exp,
+ TOOL_PREVIEW_LINES, details_all);
+ }
+ break;
+ }
+
+ case BLK_STATUS:
+ case BLK_SYSTEM:
+ snprintf(head, sizeof(head), "%s%s%s", b->title ? b->title : "",
+ (b->title && b->body && b->body[0]) ? " " : "",
+ b->body ? b->body : "");
+ dlines_push(dl, head, CP_MUTED, 0, 1);
+ break;
+ }
+}
+
+static void build_display(tui_t *t, dlines_t *dl, int width) {
+ dlines_free(dl);
+ dl->lines = NULL;
+ dl->n = dl->cap = 0;
+ for (int i = 0; i < t->hist.n; i++)
+ block_to_lines(&t->hist.items[i], width, t->details_all, dl);
+ if (t->as_live)
+ block_to_lines(&t->live, width, t->details_all, dl);
+}
+
+static void draw_ansi_line(WINDOW *win, int y, int maxw, const char *s,
+ int base_pair, int base_bold, int base_dim) {
+ sty_t sty = {0};
+ sty.pair = base_pair;
+ if (base_bold)
+ sty.attrs |= A_BOLD;
+ if (base_dim)
+ sty.attrs |= A_DIM;
+ int col = 0;
+ size_t n = s ? strlen(s) : 0;
+ size_t i = 0;
+ while (i < n && col < maxw) {
+ size_t sg = sgr_parse(s + i, n - i, &sty);
+ if (sg) {
+ i += sg;
+ continue;
+ }
+ size_t skip = ansi_skip(s + i, n - i);
+ if (skip) {
+ i += skip;
+ continue;
+ }
+ wchar_t wc;
+ size_t eaten;
+ utf8_decode(s + i, n - i, &wc, &eaten);
+ int cw = utf8_width(wc);
+ if (col + cw > maxw)
+ break;
+ int attrs = sty.attrs;
+ if (sty.pair && has_colors())
+ attrs |= COLOR_PAIR(sty.pair);
+ else if (base_pair && has_colors())
+ attrs |= COLOR_PAIR(base_pair);
+ put_wc(win, y, col, wc, attrs);
+ col += cw;
+ i += eaten;
+ }
+}
+
+static void draw_header(tui_t *t) {
+ if (!t->header)
+ return;
+ const cst_agent_config_t *cfg = cst_agent_config(t->agent);
+ werase(t->header);
+ if (has_colors())
+ wbkgd(t->header, COLOR_PAIR(CP_HEADER));
+ char sid[16] = {0};
+ short_session_id(t, sid, sizeof(sid));
+ if (cfg)
+ mvwprintw(t->header, 0, 1, " CST-Agent · %s · %s ", cfg->model,
+ sid[0] ? sid : "—");
+ else
+ mvwprintw(t->header, 0, 1, " CST-Agent ");
+ wrefresh(t->header);
+}
+
+static void draw_status(tui_t *t) {
+ if (!t->status)
+ return;
+ werase(t->status);
+ if (has_colors())
+ wbkgd(t->status, COLOR_PAIR(CP_STATUS));
+ const char *msg = t->status_msg[0] ? t->status_msg : "ready";
+ size_t n = strlen(msg);
+ size_t fit = utf8_fit_cols(msg, n, t->cols > 2 ? t->cols - 2 : 1);
+ mvwaddnstr(t->status, 0, 1, msg, (int)fit);
+ wrefresh(t->status);
+}
+
+static void draw_input(tui_t *t) {
+ if (!t->input)
+ return;
+ werase(t->input);
+ if (has_colors())
+ wbkgd(t->input, COLOR_PAIR(CP_INPUT));
+
+ int h = t->input_rows > 0 ? t->input_rows : 1;
+ int maxw = t->cols - 4;
+ if (maxw < 8)
+ maxw = 8;
+
+ /* Split inbuf into visual lines (soft wrap by cols + hard \n). */
+ const char *s = t->inbuf;
+ size_t n = t->in_len;
+ int line = 0;
+ int col = 3;
+ mvwaddstr(t->input, 0, 0, " > ");
+ size_t i = 0;
+ int cur_line = 0, cur_col = 3;
+ /* Track cursor at end of buffer for simplicity. */
+ while (i < n && line < h) {
+ if (s[i] == '\n') {
+ line++;
+ col = 3;
+ if (line < h)
+ mvwaddstr(t->input, line, 0, " ");
+ i++;
+ cur_line = line < h ? line : h - 1;
+ cur_col = 3;
+ continue;
+ }
+ wchar_t wc;
+ size_t eaten;
+ utf8_decode(s + i, n - i, &wc, &eaten);
+ int cw = utf8_width(wc);
+ if (col + cw > t->cols - 1 && col > 3) {
+ line++;
+ col = 3;
+ if (line < h)
+ mvwaddstr(t->input, line, 0, " ");
+ }
+ if (line >= h)
+ break;
+ put_wc(t->input, line, col, wc, has_colors() ? COLOR_PAIR(CP_INPUT) : 0);
+ col += cw;
+ i += eaten;
+ cur_line = line;
+ cur_col = col;
+ }
+ if (cur_col >= t->cols)
+ cur_col = t->cols - 1;
+ if (cur_line >= h)
+ cur_line = h - 1;
+ wmove(t->input, cur_line, cur_col);
+ wrefresh(t->input);
+}
+
+static void draw_log(tui_t *t) {
+ if (!t->logw)
+ return;
+ werase(t->logw);
+ if (has_colors())
+ wbkgd(t->logw, COLOR_PAIR(CP_PANEL));
+ int h, w;
+ getmaxyx(t->logw, h, w);
+ if (h < 1 || w < 1)
+ return;
+
+ dlines_t dl = {0};
+ build_display(t, &dl, w);
+ int total = dl.n > 0 ? dl.n : 1;
+ int max_scroll = total - h;
+ if (max_scroll < 0)
+ max_scroll = 0;
+ if (t->log_scroll > max_scroll)
+ t->log_scroll = max_scroll;
+ if (t->log_scroll < 0)
+ t->log_scroll = 0;
+ int start = total - h - t->log_scroll;
+ if (start < 0)
+ start = 0;
+
+ for (int row = 0; row < h; row++) {
+ int li = start + row;
+ if (li < 0 || li >= dl.n)
+ continue;
+ dline_t *L = &dl.lines[li];
+ draw_ansi_line(t->logw, row, w, L->text, L->pair, L->bold, L->dim);
+ }
+ dlines_free(&dl);
+ wrefresh(t->logw);
+}
+
+static void redraw_all(tui_t *t) {
+ theme_paint_root();
+ draw_header(t);
+ draw_log(t);
+ draw_status(t);
+ draw_input(t);
+}
+
+/* --- session reload ------------------------------------------------------- */
+
+static void push_system_line(tui_t *t, const char *fmt, ...) {
+ char tmp[1024];
+ va_list ap;
+ va_start(ap, fmt);
+ vsnprintf(tmp, sizeof(tmp), fmt, ap);
+ va_end(ap);
+ block_t *b = blocks_push(&t->hist);
+ if (!b)
+ return;
+ b->kind = BLK_SYSTEM;
+ block_set_body(b, tmp, strlen(tmp));
+}
+
+static void print_welcome(tui_t *t) {
+ const cst_agent_config_t *cfg = cst_agent_config(t->agent);
+ push_system_line(t, "Welcome to CST-Agent");
+ push_system_line(
+ t, "Commands: /new /session /model /compact /details /help | wheel");
+ push_system_line(t, "Tab/t Thought · Enter tool/send · Ctrl+N newline · ↑↓ hist");
+ push_system_line(t, "Esc cancel · busy Enter queues · perm y/n/a=always");
+ if (cfg)
+ push_system_line(t, "Model %s @ %s", cfg->model, cfg->api_base);
+ push_system_line(t, "");
+}
+
+static void reload_hist_from_session(tui_t *t) {
+ live_end_cleanup(t);
+ blocks_clear(&t->hist);
+ t->log_scroll = 0;
+ t->think_idx = -1;
+ t->last_tool_idx = -1;
+ t->focus_think_idx = -1;
+
+ print_welcome(t);
+
+ cst_session_t *s = cst_agent_session(t->agent);
+ if (!s)
+ return;
+ int n = cst_session_count(s);
+ int non_sys = 0;
+ for (int i = 0; i < n; i++) {
+ const cst_message_t *m = cst_session_get(s, i);
+ if (m && m->role != CST_ROLE_SYSTEM)
+ non_sys++;
+ }
+ int skip = non_sys > REPLAY_MSGS ? non_sys - REPLAY_MSGS : 0;
+ int seen = 0;
+ for (int i = 0; i < n; i++) {
+ const cst_message_t *m = cst_session_get(s, i);
+ if (!m || m->role == CST_ROLE_SYSTEM)
+ continue;
+ if (seen++ < skip)
+ continue;
+ block_t *b = blocks_push(&t->hist);
+ if (!b)
+ continue;
+ if (m->role == CST_ROLE_USER) {
+ b->kind = BLK_USER;
+ block_set_title(b, "You");
+ block_set_body(b, m->content ? m->content : "",
+ m->content ? strlen(m->content) : 0);
+ b->expanded = 1;
+ } else if (m->role == CST_ROLE_ASSISTANT) {
+ b->kind = BLK_ASSISTANT;
+ block_set_title(b, "Assistant");
+ if (m->content)
+ block_set_body(b, m->content, strlen(m->content));
+ b->expanded = 1;
+ block_render_assistant(b);
+ } else if (m->role == CST_ROLE_TOOL) {
+ b->kind = BLK_TOOL;
+ snprintf(b->tool_name, sizeof(b->tool_name), "%s",
+ m->tool_name[0] ? m->tool_name : "tool");
+ snprintf(b->tool_id, sizeof(b->tool_id), "%s", m->tool_call_id);
+ snprintf(b->meta, sizeof(b->meta), "done");
+ if (m->content)
+ block_set_body(b, m->content, strlen(m->content));
+ {
+ char path[256];
+ diff_extract_path(m->content, NULL, path, sizeof(path));
+ block_set_title(b, path[0] ? path : "");
+ }
+ /* OpenCode: write/edit cards open (still fold at DIFF_FOLD_LINES). */
+ if (strcmp(b->tool_name, "write_file") == 0 ||
+ strcmp(b->tool_name, "apply_patch") == 0)
+ b->expanded = 1;
+ else
+ b->expanded = 0;
+ t->last_tool_idx = t->hist.n - 1;
+ }
+ }
+}
+
+/* --- layout --------------------------------------------------------------- */
+
+static int input_visual_rows(const tui_t *t) {
+ if (t->in_len == 0)
+ return 1;
+ int rows = 1;
+ int col = 3;
+ size_t i = 0;
+ while (i < t->in_len) {
+ if (t->inbuf[i] == '\n') {
+ rows++;
+ col = 3;
+ i++;
+ continue;
+ }
+ wchar_t wc;
+ size_t eaten;
+ utf8_decode(t->inbuf + i, t->in_len - i, &wc, &eaten);
+ int cw = utf8_width(wc);
+ if (col + cw > t->cols - 1 && col > 3) {
+ rows++;
+ col = 3;
+ }
+ col += cw;
+ i += eaten;
+ }
+ if (rows < 1)
+ rows = 1;
+ if (rows > INPUT_MAX_ROWS)
+ rows = INPUT_MAX_ROWS;
+ return rows;
+}
+
+static void layout(tui_t *t) {
+ getmaxyx(stdscr, t->rows, t->cols);
+ theme_paint_root();
+ if (t->header)
+ delwin(t->header);
+ if (t->logw)
+ delwin(t->logw);
+ if (t->status)
+ delwin(t->status);
+ if (t->input)
+ delwin(t->input);
+
+ t->input_rows = input_visual_rows(t);
+ int chrome = 1 + 1 + t->input_rows; /* header + status + input */
+ if (chrome >= t->rows)
+ chrome = t->rows - 1;
+ int log_h = t->rows - chrome;
+ if (log_h < 1)
+ log_h = 1;
+
+ t->header = newwin(1, t->cols, 0, 0);
+ t->logw = newwin(log_h, t->cols, 1, 0);
+ t->status = newwin(1, t->cols, 1 + log_h, 0);
+ t->input = newwin(t->input_rows, t->cols, 1 + log_h + 1, 0);
+ if (has_colors()) {
+ wbkgdset(t->header, COLOR_PAIR(CP_HEADER));
+ wbkgdset(t->logw, COLOR_PAIR(CP_PANEL));
+ wbkgdset(t->status, COLOR_PAIR(CP_STATUS));
+ wbkgdset(t->input, COLOR_PAIR(CP_INPUT));
+ }
+ scrollok(t->logw, FALSE);
+ keypad(t->input, TRUE);
+ mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, NULL);
+ mouseinterval(0);
+}
+
+static void redraw_all(tui_t *t);
+static int start_turn(tui_t *t, char *prompt); /* takes ownership of prompt */
+
+static void layout_maybe_grow_input(tui_t *t) {
+ int need = input_visual_rows(t);
+ if (need != t->input_rows) {
+ layout(t);
+ wtimeout(t->input, 80);
+ redraw_all(t);
+ } else {
+ draw_input(t);
+ }
+}
+
+static void input_put_wc(tui_t *t, wchar_t wc) {
+ char mb[8];
+ mbstate_t st;
+ memset(&st, 0, sizeof(st));
+ size_t n = wcrtomb(mb, wc, &st);
+ if (n == (size_t)-1 || n == 0)
+ return;
+ if (t->in_len + n >= INPUT_CAP)
+ return;
+ memcpy(t->inbuf + t->in_len, mb, n);
+ t->in_len += n;
+ t->inbuf[t->in_len] = 0;
+ t->hist_browse = -1;
+}
+
+static void input_put_byte(tui_t *t, char ch) {
+ if (t->in_len + 1 >= INPUT_CAP)
+ return;
+ t->inbuf[t->in_len++] = ch;
+ t->inbuf[t->in_len] = 0;
+ t->hist_browse = -1;
+}
+
+static void prompt_hist_push(tui_t *t, const char *prompt) {
+ if (!prompt || !prompt[0])
+ return;
+ if (t->prompt_hist_n >= PROMPT_HIST_CAP) {
+ free(t->prompt_hist[0]);
+ memmove(t->prompt_hist, t->prompt_hist + 1,
+ (PROMPT_HIST_CAP - 1) * sizeof(char *));
+ t->prompt_hist_n = PROMPT_HIST_CAP - 1;
+ }
+ t->prompt_hist[t->prompt_hist_n++] = strdup(prompt);
+ t->hist_browse = -1;
+}
+
+static void hist_load_into_input(tui_t *t, int idx) {
+ if (idx < 0 || idx >= t->prompt_hist_n)
+ return;
+ const char *s = t->prompt_hist[idx];
+ snprintf(t->inbuf, sizeof(t->inbuf), "%s", s ? s : "");
+ t->in_len = strlen(t->inbuf);
+ t->hist_browse = idx;
+}
+
+static int queue_push(tui_t *t, const char *prompt) {
+ if (!prompt || !prompt[0])
+ return -1;
+ if (t->queue_n >= QUEUE_CAP)
+ return -1;
+ t->queue[t->queue_n] = strdup(prompt);
+ if (!t->queue[t->queue_n])
+ return -1;
+ t->queue_n++;
+ return 0;
+}
+
+static char *queue_pop(tui_t *t) {
+ if (t->queue_n <= 0)
+ return NULL;
+ char *p = t->queue[0];
+ memmove(t->queue, t->queue + 1, (size_t)(t->queue_n - 1) * sizeof(char *));
+ t->queue_n--;
+ t->queue[t->queue_n] = NULL;
+ return p;
+}
+
+static void queue_free_all(tui_t *t) {
+ for (int i = 0; i < t->queue_n; i++) {
+ free(t->queue[i]);
+ t->queue[i] = NULL;
+ }
+ t->queue_n = 0;
+}
+
+static int perm_is_always(const tui_t *t, const char *tool) {
+ if (!tool)
+ return 0;
+ if (strcmp(tool, "run_shell") == 0)
+ return t->always_shell;
+ if (strcmp(tool, "write_file") == 0 || strcmp(tool, "apply_patch") == 0)
+ return t->always_write;
+ return 0;
+}
+
+static void perm_set_always(tui_t *t, const char *tool) {
+ if (!tool)
+ return;
+ if (strcmp(tool, "run_shell") == 0)
+ t->always_shell = 1;
+ else if (strcmp(tool, "write_file") == 0 || strcmp(tool, "apply_patch") == 0)
+ t->always_write = 1;
+}
+
+/* --- hooks (may run on worker thread) ------------------------------------- */
+
+static void on_status(const char *msg, void *ud) {
+ tui_t *t = ud;
+ tui_lock(t);
+ set_status_locked(t, msg);
+ tui_unlock(t);
+}
+
+static void on_assistant_delta(const char *text, size_t len, void *ud) {
+ tui_t *t = ud;
+ tui_lock(t);
+ collapse_open_think(t);
+ if (!t->as_live)
+ live_begin(t);
+ live_feed(t, text, len);
+ t->log_scroll = 0;
+ set_status_locked(t, "streaming…");
+ tui_mark_dirty(t);
+ tui_unlock(t);
+}
+
+static void on_think_delta(const char *text, size_t len, void *ud) {
+ tui_t *t = ud;
+ tui_lock(t);
+ if (t->think_idx < 0) {
+ block_t *b = blocks_push(&t->hist);
+ if (b) {
+ b->kind = BLK_THINK;
+ block_set_title(b, "Thinking…");
+ b->expanded = 1;
+ t->think_idx = t->hist.n - 1;
+ t->focus_think_idx = t->think_idx;
+ clock_gettime(CLOCK_MONOTONIC, &t->think_t0);
+ t->think_timing = 1;
+ }
+ }
+ if (t->think_idx >= 0 && t->think_idx < t->hist.n && text && len)
+ block_append_body(&t->hist.items[t->think_idx], text, len);
+ set_status_locked(t, "thinking…");
+ t->log_scroll = 0;
+ tui_mark_dirty(t);
+ tui_unlock(t);
+}
+
+static void on_tool_event(const char *phase, const ctl_tool_call_t *call,
+ const char *detail, void *ud) {
+ tui_t *t = ud;
+ tui_lock(t);
+ collapse_open_think(t);
+ if (t->as_live &&
+ (strcmp(phase, "open") == 0 || strcmp(phase, "ready") == 0))
+ live_seal(t);
+ if (!call) {
+ tui_unlock(t);
+ return;
+ }
+
+ int idx = find_tool_block(t, call->id);
+ block_t *b = NULL;
+ if (idx < 0) {
+ b = blocks_push(&t->hist);
+ if (!b) {
+ tui_unlock(t);
+ return;
+ }
+ b->kind = BLK_TOOL;
+ snprintf(b->tool_name, sizeof(b->tool_name), "%s", call->name);
+ snprintf(b->tool_id, sizeof(b->tool_id), "%s", call->id);
+ b->expanded = 0;
+ idx = t->hist.n - 1;
+ } else {
+ b = &t->hist.items[idx];
+ }
+ t->last_tool_idx = idx;
+
+ char args[160];
+ tool_arg_summary(call, args, sizeof(args));
+ block_set_title(b, args);
+
+ if (strcmp(phase, "open") == 0) {
+ snprintf(b->meta, sizeof(b->meta), "pending");
+ set_status_locked(t, "tool pending…");
+ } else if (strcmp(phase, "arg") == 0) {
+ snprintf(b->meta, sizeof(b->meta), "args…");
+ } else if (strcmp(phase, "ready") == 0) {
+ snprintf(b->meta, sizeof(b->meta), "ready");
+ set_status_locked(t, "tool confirmed");
+ } else if (strcmp(phase, "run") == 0) {
+ snprintf(b->meta, sizeof(b->meta), "running");
+ set_status_locked(t, "running tool…");
+ } else if (strcmp(phase, "denied") == 0) {
+ snprintf(b->meta, sizeof(b->meta), "denied");
+ block_set_body(b, "denied by user", strlen("denied by user"));
+ set_status_locked(t, "tool denied");
+ } else if (strcmp(phase, "done") == 0) {
+ snprintf(b->meta, sizeof(b->meta), "%s", detail ? detail : "done");
+ attach_tool_output_from_session(t, b);
+ /* Auto-expand write/patch cards so diffs are visible like OpenCode. */
+ if (strcmp(call->name, "write_file") == 0 ||
+ strcmp(call->name, "apply_patch") == 0)
+ b->expanded = 1;
+ set_status_locked(t, "tool done");
+ }
+
+ t->log_scroll = 0;
+ tui_mark_dirty(t);
+ tui_unlock(t);
+}
+
+static int on_permission(const char *tool_name, const char *detail, void *ud) {
+ tui_t *t = ud;
+
+ tui_lock(t);
+ if (perm_is_always(t, tool_name)) {
+ tui_unlock(t);
+ return 1;
+ }
+ t->awaiting_perm = 1;
+ t->perm_ask = 1;
+ t->perm_ans = -1;
+ snprintf(t->perm_tool, sizeof(t->perm_tool), "%s",
+ tool_name ? tool_name : "tool");
+ snprintf(t->perm_detail, sizeof(t->perm_detail), "%s",
+ detail ? detail : "");
+ {
+ char msg[480];
+ snprintf(msg, sizeof(msg), "Allow %s? [y/n/a] a=always %.280s",
+ t->perm_tool, t->perm_detail);
+ set_status_locked(t, msg);
+ }
+ tui_unlock(t);
+
+ for (;;) {
+ tui_lock(t);
+ int ans = t->perm_ans;
+ tui_unlock(t);
+ if (ans >= 0) {
+ tui_lock(t);
+ t->awaiting_perm = 0;
+ t->perm_ask = 0;
+ tui_unlock(t);
+ return ans;
+ }
+ usleep(20000);
+ }
+}
+
+/* --- modal picker (↑↓ Enter Esc) ------------------------------------------ */
+
+/* items: n rows of labels (each ≤127 chars). Returns index or -1. */
+static int picker_run(tui_t *t, const char *title, char items[][128], int n,
+ int cur_idx) {
+ if (n <= 0)
+ return -1;
+ if (cur_idx < 0 || cur_idx >= n)
+ cur_idx = 0;
+
+ int sel = cur_idx;
+ int top = 0;
+ wtimeout(stdscr, -1);
+ keypad(stdscr, TRUE);
+ curs_set(0);
+
+ for (;;) {
+ int rows, cols;
+ getmaxyx(stdscr, rows, cols);
+ int box_h = rows > 4 ? rows - 2 : rows;
+ if (box_h > n + 2)
+ box_h = n + 2;
+ if (box_h < 3)
+ box_h = rows > 0 ? rows : 3;
+ int visible = box_h - 2;
+ if (visible < 1)
+ visible = 1;
+ if (sel < top)
+ top = sel;
+ if (sel >= top + visible)
+ top = sel - visible + 1;
+
+ werase(stdscr);
+ if (has_colors())
+ wbkgd(stdscr, COLOR_PAIR(CP_PANEL));
+ mvprintw(0, 1, "%s (↑↓ Enter · Esc cancel)", title ? title : "Select");
+ for (int i = 0; i < visible && top + i < n; i++) {
+ int idx = top + i;
+ int y = 1 + i;
+ if (idx == sel) {
+ if (has_colors())
+ attron(COLOR_PAIR(CP_PRIMARY) | A_BOLD);
+ else
+ attron(A_REVERSE);
+ } else if (has_colors()) {
+ attron(COLOR_PAIR(CP_MUTED));
+ }
+ mvprintw(y, 2, "%s %.*s", idx == cur_idx ? "*" : " ", cols - 6,
+ items[idx]);
+ if (idx == sel) {
+ if (has_colors())
+ attroff(COLOR_PAIR(CP_PRIMARY) | A_BOLD);
+ else
+ attroff(A_REVERSE);
+ } else if (has_colors()) {
+ attroff(COLOR_PAIR(CP_MUTED));
+ }
+ }
+ refresh();
+
+ wint_t wch = 0;
+ int rc = get_wch(&wch);
+ if (rc == ERR)
+ continue;
+ if (rc == KEY_CODE_YES) {
+ if (wch == KEY_UP) {
+ if (sel > 0)
+ sel--;
+ continue;
+ }
+ if (wch == KEY_DOWN) {
+ if (sel + 1 < n)
+ sel++;
+ continue;
+ }
+ if (wch == KEY_RESIZE)
+ continue;
+ if (wch == KEY_ENTER)
+ break;
+ continue;
+ }
+ if (wch == 10 || wch == 13) /* LF/CR */
+ break;
+ if (wch == 27) { /* Esc */
+ sel = -1;
+ break;
+ }
+ }
+
+ curs_set(1);
+ layout(t);
+ wtimeout(t->input, 80);
+ keypad(t->input, TRUE);
+ redraw_all(t);
+ return sel;
+}
+
+/* --- slash commands ------------------------------------------------------- */
+
+static void cmd_help(tui_t *t) {
+ push_system_line(t, "");
+ push_system_line(t, "Slash commands:");
+ push_system_line(t, " /new, /clear new session");
+ push_system_line(t, " /session [id] pick or switch session");
+ push_system_line(t, " /sessions same as /session");
+ push_system_line(t, " /model [name] pick or set model");
+ push_system_line(t, " /compact, /summarize compact context");
+ push_system_line(t, " /details toggle tool body expand");
+ push_system_line(t, " /help this help");
+ push_system_line(t, " /quit, /q, /exit quit");
+ push_system_line(t, "Keys: Tab/t Thought · Enter send/tool · Ctrl+N newline · ↑↓ hist");
+ push_system_line(t, " Esc cancel · busy Enter queues · perm y/n/a");
+ push_system_line(t, "");
+}
+
+static int handle_slash(tui_t *t, const char *line, int *should_quit) {
+ *should_quit = 0;
+ if (!line || line[0] != '/')
+ return 0;
+
+ char buf[INPUT_CAP];
+ snprintf(buf, sizeof(buf), "%s", line);
+ char *cmd = buf + 1;
+ while (*cmd == ' ')
+ cmd++;
+ char *arg = cmd;
+ while (*arg && *arg != ' ')
+ arg++;
+ if (*arg) {
+ *arg++ = 0;
+ while (*arg == ' ')
+ arg++;
+ }
+
+ if (strcmp(cmd, "new") == 0 || strcmp(cmd, "clear") == 0) {
+ char id[128] = {0};
+ if (cst_agent_new_session(t->agent, id, sizeof(id)) != 0) {
+ push_system_line(t, "Error: failed to create session");
+ set_status(t, "new session failed");
+ } else {
+ live_end_cleanup(t);
+ blocks_clear(&t->hist);
+ t->log_scroll = 0;
+ t->think_idx = -1;
+ print_welcome(t);
+ push_system_line(t, "(new session %s — empty)", id[0] ? id : "?");
+ status_idle(t);
+ }
+ return 1;
+ }
+
+ if (strcmp(cmd, "session") == 0 || strcmp(cmd, "sessions") == 0) {
+ const cst_agent_config_t *cfg = cst_agent_config(t->agent);
+ if (!cfg) {
+ push_system_line(t, "Error: no agent config");
+ return 1;
+ }
+ if (!arg || !arg[0]) {
+ char ids[64][128];
+ int n = cst_agent_store_list(cfg->workspace_root, ids, 64);
+ if (n <= 0) {
+ push_system_line(t, "Sessions: (none)");
+ status_idle(t);
+ return 1;
+ }
+ char cur[128] = {0};
+ cst_agent_store_get_current(cfg->workspace_root, cur, sizeof(cur));
+ int cur_idx = 0;
+ for (int i = 0; i < n; i++) {
+ if (cur[0] && strcmp(ids[i], cur) == 0) {
+ cur_idx = i;
+ break;
+ }
+ }
+ int pick = picker_run(t, "Session", ids, n, cur_idx);
+ if (pick < 0) {
+ set_status(t, "session pick cancelled");
+ return 1;
+ }
+ char chosen[128];
+ snprintf(chosen, sizeof(chosen), "%s", ids[pick]);
+ if (cst_agent_switch_session(t->agent, chosen) != 0) {
+ push_system_line(t, "Error: cannot switch to session '%s'", chosen);
+ set_status(t, "switch failed");
+ } else {
+ reload_hist_from_session(t);
+ push_system_line(t, "(switched to %s)", chosen);
+ status_idle(t);
+ }
+ return 1;
+ }
+ if (cst_agent_switch_session(t->agent, arg) != 0) {
+ push_system_line(t, "Error: cannot switch to session '%s'", arg);
+ set_status(t, "switch failed");
+ } else {
+ reload_hist_from_session(t);
+ push_system_line(t, "(switched to %s)", arg);
+ status_idle(t);
+ }
+ return 1;
+ }
+
+ if (strcmp(cmd, "model") == 0) {
+ cst_agent_config_t *mcfg = cst_agent_config_mut(t->agent);
+ if (!mcfg) {
+ push_system_line(t, "Error: no agent config");
+ return 1;
+ }
+ if (arg && arg[0]) {
+ snprintf(mcfg->model, sizeof(mcfg->model), "%s", arg);
+ push_system_line(t, "(model → %s)", mcfg->model);
+ set_status(t, mcfg->model);
+ redraw_all(t);
+ return 1;
+ }
+ char models[32][128];
+ int n = cst_models_list(mcfg->model, models, 32);
+ if (n <= 0) {
+ push_system_line(t, "No models available (set CST_MODELS)");
+ return 1;
+ }
+ int cur_idx = 0;
+ for (int i = 0; i < n; i++) {
+ if (strcmp(models[i], mcfg->model) == 0) {
+ cur_idx = i;
+ break;
+ }
+ }
+ int pick = picker_run(t, "Model", models, n, cur_idx);
+ if (pick < 0) {
+ set_status(t, "model pick cancelled");
+ return 1;
+ }
+ snprintf(mcfg->model, sizeof(mcfg->model), "%s", models[pick]);
+ push_system_line(t, "(model → %s)", mcfg->model);
+ set_status(t, mcfg->model);
+ redraw_all(t);
+ return 1;
+ }
+
+ if (strcmp(cmd, "compact") == 0 || strcmp(cmd, "summarize") == 0) {
+ char err[512] = {0};
+ set_status(t, "compacting…");
+ redraw_all(t);
+ doupdate();
+ if (cst_agent_compact(t->agent, err, sizeof(err)) != 0) {
+ push_system_line(t, "Error: compact failed: %s", err[0] ? err : "?");
+ set_status(t, err[0] ? err : "compact failed");
+ } else {
+ reload_hist_from_session(t);
+ push_system_line(t, "(session compacted)");
+ status_idle(t);
+ }
+ return 1;
+ }
+
+ if (strcmp(cmd, "details") == 0) {
+ t->details_all = !t->details_all;
+ push_system_line(t, "tool details: %s", t->details_all ? "expanded" : "collapsed");
+ set_status(t, t->details_all ? "details on" : "details off");
+ return 1;
+ }
+
+ if (strcmp(cmd, "help") == 0 || strcmp(cmd, "?") == 0) {
+ cmd_help(t);
+ return 1;
+ }
+
+ if (strcmp(cmd, "quit") == 0 || strcmp(cmd, "q") == 0 ||
+ strcmp(cmd, "exit") == 0) {
+ *should_quit = 1;
+ return 1;
+ }
+
+ push_system_line(t, "Unknown command: /%s (try /help)", cmd);
+ set_status(t, "unknown slash command");
+ return 1;
+}
+
+static void toggle_think(tui_t *t) {
+ int idx = t->focus_think_idx;
+ if (idx < 0) {
+ for (int i = t->hist.n - 1; i >= 0; i--) {
+ if (t->hist.items[i].kind == BLK_THINK) {
+ idx = i;
+ break;
+ }
+ }
+ }
+ if (idx < 0 || idx >= t->hist.n)
+ return;
+ block_t *b = &t->hist.items[idx];
+ if (b->kind != BLK_THINK)
+ return;
+ b->expanded = !b->expanded;
+ /* Keep timed title (Thought · Ns) if already set. */
+ if (b->expanded && (!b->title || !b->title[0]))
+ block_set_title(b, "Thought");
+ t->focus_think_idx = idx;
+ draw_log(t);
+}
+
+static void toggle_last_tool(tui_t *t) {
+ int idx = t->last_tool_idx;
+ if (idx < 0 || idx >= t->hist.n) {
+ for (int i = t->hist.n - 1; i >= 0; i--) {
+ if (t->hist.items[i].kind == BLK_TOOL) {
+ idx = i;
+ break;
+ }
+ }
+ }
+ if (idx < 0 || idx >= t->hist.n)
+ return;
+ block_t *b = &t->hist.items[idx];
+ if (b->kind != BLK_TOOL)
+ return;
+ b->expanded = !b->expanded;
+ t->last_tool_idx = idx;
+ draw_log(t);
+}
+
+/* --- turn worker ---------------------------------------------------------- */
+
+typedef struct {
+ tui_t *t;
+ char *prompt;
+} turn_arg_t;
+
+static void *turn_thread(void *arg) {
+ turn_arg_t *a = arg;
+ tui_t *t = a->t;
+ cst_agent_hooks_t hooks = {0};
+ hooks.on_status = on_status;
+ hooks.on_assistant_delta = on_assistant_delta;
+ hooks.on_think_delta = on_think_delta;
+ hooks.on_tool_event = on_tool_event;
+ hooks.on_permission = on_permission;
+ hooks.userdata = t;
+
+ char err[512];
+ err[0] = 0;
+ int rc = cst_agent_run_turn_ex(t->agent, a->prompt, &hooks, err, sizeof(err));
+
+ tui_lock(t);
+ t->turn_rc = rc;
+ snprintf(t->turn_err, sizeof(t->turn_err), "%s", err);
+ collapse_open_think(t);
+ if (t->as_live)
+ live_seal(t);
+ for (int i = 0; i < t->hist.n; i++) {
+ if (t->hist.items[i].kind == BLK_TOOL)
+ attach_tool_output_from_session(t, &t->hist.items[i]);
+ }
+ t->turn_done = 1;
+ t->busy = 0;
+ tui_mark_dirty(t);
+ tui_unlock(t);
+
+ free(a->prompt);
+ free(a);
+ return NULL;
+}
+
+static void request_cancel_ui(tui_t *t) {
+ cst_agent_request_cancel(t->agent);
+ set_status(t, "cancelling…");
+ draw_status(t);
+}
+
+/* Owns prompt. Returns 0 ok, -1 fail, -2 quit. */
+static int start_turn(tui_t *t, char *prompt) {
+ if (!prompt)
+ return -1;
+ if (strcmp(prompt, ":q") == 0 || strcmp(prompt, "quit") == 0 ||
+ strcmp(prompt, ":quit") == 0) {
+ free(prompt);
+ return -2;
+ }
+ if (prompt[0] == '/') {
+ int should_quit = 0;
+ handle_slash(t, prompt, &should_quit);
+ free(prompt);
+ t->log_scroll = 0;
+ redraw_all(t);
+ return should_quit ? -2 : 0;
+ }
+
+ prompt_hist_push(t, prompt);
+
+ block_t *ub = blocks_push(&t->hist);
+ if (ub) {
+ ub->kind = BLK_USER;
+ block_set_title(ub, "You");
+ block_set_body(ub, prompt, strlen(prompt));
+ ub->expanded = 1;
+ }
+ t->think_idx = -1;
+ t->log_scroll = 0;
+ set_status(t, "streaming…");
+ redraw_all(t);
+
+ turn_arg_t *ta = calloc(1, sizeof(*ta));
+ if (!ta) {
+ free(prompt);
+ return -1;
+ }
+ ta->t = t;
+ ta->prompt = prompt;
+ t->busy = 1;
+ t->turn_done = 0;
+ pthread_t th;
+ if (pthread_create(&th, NULL, turn_thread, ta) != 0) {
+ t->busy = 0;
+ free(prompt);
+ free(ta);
+ push_system_line(t, "Error: failed to start turn thread");
+ status_idle(t);
+ redraw_all(t);
+ return -1;
+ }
+ pthread_detach(th);
+ return 0;
+}
+
+static int try_start_queued(tui_t *t) {
+ if (t->busy || t->queue_n <= 0)
+ return 0;
+ char *next = queue_pop(t);
+ if (!next)
+ return 0;
+ return start_turn(t, next);
+}
+
+static int submit_input(tui_t *t, int busy) {
+ if (t->in_len == 0) {
+ if (!busy && t->queue_n > 0)
+ return try_start_queued(t);
+ if (!busy)
+ toggle_last_tool(t);
+ return 0;
+ }
+
+ char *prompt = strdup(t->inbuf);
+ t->in_len = 0;
+ t->inbuf[0] = 0;
+ t->hist_browse = -1;
+ layout_maybe_grow_input(t);
+ if (!prompt)
+ return 0;
+
+ if (busy) {
+ if (queue_push(t, prompt) != 0) {
+ push_system_line(t, "(queue full — max %d)", QUEUE_CAP);
+ set_status(t, "queue full");
+ free(prompt);
+ } else {
+ char msg[64];
+ snprintf(msg, sizeof(msg), "queued (%d)", t->queue_n);
+ set_status(t, msg);
+ free(prompt);
+ }
+ draw_status(t);
+ return 0;
+ }
+ return start_turn(t, prompt);
+}
+
+/* --- main loop ------------------------------------------------------------ */
+
+int cst_tui_run(cst_agent_t *agent) {
+ if (!setlocale(LC_ALL, ""))
+ setlocale(LC_ALL, "C.UTF-8");
+
+ initscr();
+ cbreak();
+ noecho();
+ keypad(stdscr, TRUE);
+ curs_set(1);
+ theme_init();
+
+ tui_t t;
+ memset(&t, 0, sizeof(t));
+ t.agent = agent;
+ t.think_idx = -1;
+ t.last_tool_idx = -1;
+ t.focus_think_idx = -1;
+ t.hist_browse = -1;
+ pthread_mutex_init(&t.mu, NULL);
+ blocks_init(&t.hist);
+ if (!t.hist.items) {
+ endwin();
+ return -1;
+ }
+
+ layout(&t);
+ reload_hist_from_session(&t);
+ status_idle(&t);
+ redraw_all(&t);
+ wtimeout(t.input, 80);
+
+ for (;;) {
+ tui_lock(&t);
+ int dirty = t.dirty;
+ int turn_done = t.turn_done;
+ int turn_rc = t.turn_rc;
+ char turn_err[512];
+ snprintf(turn_err, sizeof(turn_err), "%s", t.turn_err);
+ int perm_ask = t.perm_ask;
+ int busy = t.busy;
+ t.dirty = 0;
+ if (turn_done)
+ t.turn_done = 0;
+ tui_unlock(&t);
+
+ if (dirty)
+ redraw_all(&t);
+
+ if (turn_done) {
+ tui_lock(&t);
+ if (turn_rc != 0) {
+ push_system_line(&t, "Error: %s", turn_err[0] ? turn_err : "?");
+ set_status_locked(&t, turn_err[0] ? turn_err : "turn failed");
+ } else {
+ status_idle(&t);
+ }
+ t.log_scroll = 0;
+ tui_unlock(&t);
+ redraw_all(&t);
+ if (try_start_queued(&t) == -2)
+ break;
+ continue;
+ }
+
+ if (perm_ask) {
+ t.awaiting_perm = 1;
+ draw_status(&t);
+ wtimeout(t.input, -1);
+ for (;;) {
+ wint_t wch = 0;
+ int rc = wget_wch(t.input, &wch);
+ if (rc == ERR)
+ continue;
+ if (rc == KEY_CODE_YES && wch == KEY_RESIZE) {
+ layout(&t);
+ wtimeout(t.input, -1);
+ redraw_all(&t);
+ continue;
+ }
+ if (wch == L'y' || wch == L'Y') {
+ tui_lock(&t);
+ t.perm_ans = 1;
+ tui_unlock(&t);
+ break;
+ }
+ if (wch == L'n' || wch == L'N') {
+ tui_lock(&t);
+ t.perm_ans = 0;
+ tui_unlock(&t);
+ break;
+ }
+ if (wch == L'a' || wch == L'A') {
+ tui_lock(&t);
+ perm_set_always(&t, t.perm_tool);
+ t.perm_ans = 1;
+ tui_unlock(&t);
+ break;
+ }
+ }
+ wtimeout(t.input, 80);
+ t.awaiting_perm = 0;
+ continue;
+ }
+
+ wint_t wch = 0;
+ int rc = wget_wch(t.input, &wch);
+ if (rc == ERR)
+ continue;
+
+ if (rc == KEY_CODE_YES) {
+ if (wch == KEY_RESIZE) {
+ layout(&t);
+ wtimeout(t.input, 80);
+ redraw_all(&t);
+ continue;
+ }
+ if (wch == KEY_MOUSE) {
+ MEVENT ev;
+ if (getmouse(&ev) == OK) {
+ if (ev.bstate & BUTTON4_PRESSED) {
+ t.log_scroll += SCROLL_SPEED;
+ draw_log(&t);
+ } else if (ev.bstate & BUTTON5_PRESSED) {
+ t.log_scroll -= SCROLL_SPEED;
+ if (t.log_scroll < 0)
+ t.log_scroll = 0;
+ draw_log(&t);
+ }
+ }
+ continue;
+ }
+ if (wch == KEY_PPAGE) {
+ t.log_scroll += (t.rows / 2);
+ draw_log(&t);
+ continue;
+ }
+ if (wch == KEY_NPAGE) {
+ t.log_scroll -= (t.rows / 2);
+ if (t.log_scroll < 0)
+ t.log_scroll = 0;
+ draw_log(&t);
+ continue;
+ }
+ if (wch == KEY_UP) {
+ if (t.prompt_hist_n <= 0)
+ continue;
+ int idx = t.hist_browse;
+ if (idx < 0)
+ idx = t.prompt_hist_n - 1;
+ else if (idx > 0)
+ idx--;
+ hist_load_into_input(&t, idx);
+ layout_maybe_grow_input(&t);
+ continue;
+ }
+ if (wch == KEY_DOWN) {
+ if (t.hist_browse < 0)
+ continue;
+ if (t.hist_browse + 1 >= t.prompt_hist_n) {
+ t.in_len = 0;
+ t.inbuf[0] = 0;
+ t.hist_browse = -1;
+ } else {
+ hist_load_into_input(&t, t.hist_browse + 1);
+ }
+ layout_maybe_grow_input(&t);
+ continue;
+ }
+ if (wch == KEY_BACKSPACE) {
+ size_t plen = utf8_prev_len(t.inbuf, t.in_len);
+ if (plen) {
+ t.in_len -= plen;
+ t.inbuf[t.in_len] = 0;
+ t.hist_browse = -1;
+ layout_maybe_grow_input(&t);
+ }
+ continue;
+ }
+ /* Enter (keypad) → send */
+ if (wch == KEY_ENTER) {
+ int st = submit_input(&t, busy);
+ if (st == -2)
+ break;
+ continue;
+ }
+ continue;
+ }
+
+ /* Ctrl+C */
+ if (wch == 3) {
+ if (busy) {
+ request_cancel_ui(&t);
+ continue;
+ }
+ if (t.in_len == 0)
+ break;
+ t.in_len = 0;
+ t.inbuf[0] = 0;
+ t.hist_browse = -1;
+ layout_maybe_grow_input(&t);
+ continue;
+ }
+
+ /* Ctrl+U clear line */
+ if (wch == 21) {
+ t.in_len = 0;
+ t.inbuf[0] = 0;
+ t.hist_browse = -1;
+ layout_maybe_grow_input(&t);
+ continue;
+ }
+
+ /* Esc */
+ if (wch == 27) {
+ if (t.awaiting_perm)
+ continue;
+ if (busy) {
+ request_cancel_ui(&t);
+ continue;
+ }
+ if (t.in_len == 0)
+ break;
+ t.in_len = 0;
+ t.inbuf[0] = 0;
+ t.hist_browse = -1;
+ layout_maybe_grow_input(&t);
+ continue;
+ }
+
+ /* Send on Enter: KEY_ENTER already handled; CR/LF also send (common).
+ * Newline insert: Ctrl+N (14). Ctrl+J==LF usually collides with Enter. */
+ if (wch == 10 || wch == 13) {
+ int st = submit_input(&t, busy);
+ if (st == -2)
+ break;
+ continue;
+ }
+ if (wch == 14) {
+ input_put_byte(&t, '\n');
+ layout_maybe_grow_input(&t);
+ continue;
+ }
+
+ if ((wch == L'\t' || (!busy && wch == L't')) && t.in_len == 0) {
+ toggle_think(&t);
+ continue;
+ }
+
+ if (wch == 127 || wch == 8) {
+ size_t plen = utf8_prev_len(t.inbuf, t.in_len);
+ if (plen) {
+ t.in_len -= plen;
+ t.inbuf[t.in_len] = 0;
+ t.hist_browse = -1;
+ layout_maybe_grow_input(&t);
+ }
+ continue;
+ }
+
+ if (iswprint(wch) || wcwidth((wchar_t)wch) > 0) {
+ input_put_wc(&t, (wchar_t)wch);
+ layout_maybe_grow_input(&t);
+ }
+ }
+
+ if (t.busy) {
+ cst_agent_request_cancel(t.agent);
+ for (int i = 0; i < 50 && t.busy; i++)
+ usleep(100000);
+ }
+
+ queue_free_all(&t);
+ for (int i = 0; i < t.prompt_hist_n; i++)
+ free(t.prompt_hist[i]);
+
+ live_end_cleanup(&t);
+ if (t.header)
+ delwin(t.header);
+ if (t.logw)
+ delwin(t.logw);
+ if (t.status)
+ delwin(t.status);
+ if (t.input)
+ delwin(t.input);
+ blocks_free(&t.hist);
+ pthread_mutex_destroy(&t.mu);
+ endwin();
+ return 0;
+}
diff --git a/src/tui_stub.c b/src/tui_stub.c
new file mode 100644
index 0000000..6a1f197
--- /dev/null
+++ b/src/tui_stub.c
@@ -0,0 +1,13 @@
+#include "cst_agent.h"
+
+#include
+
+#ifdef CST_HEADLESS_ONLY
+int cst_tui_run(cst_agent_t *agent) {
+ (void)agent;
+ fprintf(stderr,
+ "TUI was not built into this binary (CST_BUILD_TUI=OFF).\n"
+ "Use --headless --prompt '...'\n");
+ return 1;
+}
+#endif
diff --git a/tests/test_ctl_frontier.c b/tests/test_ctl_frontier.c
new file mode 100644
index 0000000..1f0c573
--- /dev/null
+++ b/tests/test_ctl_frontier.c
@@ -0,0 +1,156 @@
+#include "ctl.h"
+
+#include
+#include
+#include
+
+typedef struct {
+ int n_open;
+ int n_arg;
+ int n_ready;
+ int n_text;
+ int n_error;
+ char last_name[CTL_MAX_NAME];
+ char last_id[CTL_MAX_ID];
+ char last_path[512];
+ size_t text_bytes;
+} sink_t;
+
+static void on_ev(const ctl_event_t *ev, void *ud) {
+ sink_t *s = ud;
+ switch (ev->type) {
+ case CTL_EV_CALL_OPEN:
+ s->n_open++;
+ snprintf(s->last_id, sizeof(s->last_id), "%s", ev->call->id);
+ snprintf(s->last_name, sizeof(s->last_name), "%s", ev->call->name);
+ break;
+ case CTL_EV_ARG:
+ s->n_arg++;
+ if (ev->call->nargs > 0) {
+ const ctl_arg_t *a = &ev->call->args[ev->call->nargs - 1];
+ if (strcmp(a->key, "path") == 0 && a->value)
+ snprintf(s->last_path, sizeof(s->last_path), "%s", a->value);
+ }
+ break;
+ case CTL_EV_CALL_READY:
+ s->n_ready++;
+ break;
+ case CTL_EV_TEXT:
+ s->n_text++;
+ s->text_bytes += ev->text_len;
+ break;
+ case CTL_EV_ERROR:
+ s->n_error++;
+ break;
+ default:
+ break;
+ }
+}
+
+static int expect(int cond, const char *msg) {
+ if (!cond) {
+ fprintf(stderr, "FAIL: %s\n", msg);
+ return 0;
+ }
+ return 1;
+}
+
+static int test_chunked_confirm(void) {
+ sink_t s = {0};
+ ctl_parser_t *p = ctl_parser_create(on_ev, &s);
+ const char *msg =
+ "I'll read the file.\n"
+ "call t1 read_file\n"
+ "arg path 10\n"
+ "README.mdx"
+ "end\n";
+ /* feed 1 byte at a time */
+ for (size_t i = 0; msg[i]; i++) {
+ if (ctl_parser_feed(p, &msg[i], 1) != CTL_OK) {
+ fprintf(stderr, "feed failed at %zu\n", i);
+ ctl_parser_destroy(p);
+ return 0;
+ }
+ }
+ int ok = 1;
+ ok &= expect(ctl_parser_finish(p) == CTL_OK, "finish ok");
+ ok &= expect(s.n_ready == 1, "one ready");
+ ok &= expect(s.n_open == 1, "one open");
+ ok &= expect(s.n_arg == 1, "one arg");
+ ok &= expect(strcmp(s.last_name, "read_file") == 0, "name");
+ ok &= expect(strcmp(s.last_path, "README.mdx") == 0, "path");
+ ok &= expect(s.n_error == 0, "no error");
+ ok &= expect(ctl_parser_pending(p) == 0, "pending 0");
+ /* pending peak should be bounded by line/arg */
+ ok &= expect(ctl_parser_pending_peak(p) <= CTL_MAX_LINE, "peak bounded");
+ ctl_parser_destroy(p);
+ return ok;
+}
+
+static int test_truncate_no_execute(void) {
+ sink_t s = {0};
+ ctl_parser_t *p = ctl_parser_create(on_ev, &s);
+ const char *msg =
+ "call t1 read_file\n"
+ "arg path 20\n"
+ "partial"; /* incomplete: need 20 bytes, then end */
+ ctl_parser_feed(p, msg, strlen(msg));
+ int ok = 1;
+ ok &= expect(s.n_ready == 0, "no ready before EOF");
+ ok &= expect(s.n_open == 1, "open confirmed");
+ ok &= expect(ctl_parser_finish(p) == CTL_ERR_CONTRACT, "finish error");
+ ok &= expect(s.n_error >= 1, "error emitted");
+ ok &= expect(s.n_ready == 0, "still no ready");
+ ctl_parser_destroy(p);
+ return ok;
+}
+
+static int test_monotonic_no_retract(void) {
+ /* Once CALL_READY fires, further feed of garbage should error, not retract */
+ sink_t s = {0};
+ ctl_parser_t *p = ctl_parser_create(on_ev, &s);
+ const char *okmsg =
+ "call a1 run_shell\n"
+ "arg cmd 5\n"
+ "ls -l"
+ "end\n";
+ ctl_parser_feed(p, okmsg, strlen(okmsg));
+ int ready = s.n_ready;
+ int ok = expect(ready == 1, "ready once");
+ /* text after is fine */
+ ctl_parser_feed(p, "done.\n", 6);
+ ok &= expect(s.n_ready == 1, "ready unchanged");
+ ctl_parser_finish(p);
+ ctl_parser_destroy(p);
+ return ok;
+}
+
+static int test_text_before_call(void) {
+ sink_t s = {0};
+ ctl_parser_t *p = ctl_parser_create(on_ev, &s);
+ const char *msg = "Hello\nWorld\n";
+ ctl_parser_feed(p, msg, strlen(msg));
+ ctl_parser_finish(p);
+ int ok = expect(s.n_text == 2, "two text lines");
+ ok &= expect(s.n_ready == 0, "no calls");
+ ctl_parser_destroy(p);
+ return ok;
+}
+
+int main(void) {
+ int nfail = 0;
+ if (!test_chunked_confirm())
+ nfail++;
+ if (!test_truncate_no_execute())
+ nfail++;
+ if (!test_monotonic_no_retract())
+ nfail++;
+ if (!test_text_before_call())
+ nfail++;
+ if (nfail) {
+ fprintf(stderr, "%d test(s) failed\n", nfail);
+ return 1;
+ }
+ printf("test_ctl_frontier: OK\n");
+ return 0;
+}
diff --git a/tests/test_json_baseline.c b/tests/test_json_baseline.c
new file mode 100644
index 0000000..7df351b
--- /dev/null
+++ b/tests/test_json_baseline.c
@@ -0,0 +1,55 @@
+#include "cst_agent.h"
+
+#include
+#include
+
+static int expect(int cond, const char *msg) {
+ if (!cond) {
+ fprintf(stderr, "FAIL: %s\n", msg);
+ return 0;
+ }
+ return 1;
+}
+
+static int test_complete_json(void) {
+ const char *j =
+ "Here is a call {\"name\":\"read_file\",\"arguments\":{\"path\":\"a.c\"}} "
+ "done";
+ cst_json_tool_t t;
+ int ok = cst_json_try_parse_tool(j, strlen(j), &t);
+ int pass = expect(ok == 1, "parse ok");
+ pass &= expect(t.ok == 1, "ok flag");
+ pass &= expect(strcmp(t.name, "read_file") == 0, "name");
+ pass &= expect(strstr(t.arguments_json, "path") != NULL, "args");
+ return pass;
+}
+
+static int test_truncated_json(void) {
+ const char *j = "{\"name\":\"read_file\",\"arguments\":{\"path\":\"a";
+ cst_json_tool_t t;
+ int ok = cst_json_try_parse_tool(j, strlen(j), &t);
+ return expect(ok == 0, "truncated must fail");
+}
+
+static int test_unclosed_brace(void) {
+ const char *j = "{\"name\":\"run_shell\",\"arguments\":{\"cmd\":\"ls\"";
+ cst_json_tool_t t;
+ int ok = cst_json_try_parse_tool(j, strlen(j), &t);
+ return expect(ok == 0, "unclosed brace fails");
+}
+
+int main(void) {
+ int nfail = 0;
+ if (!test_complete_json())
+ nfail++;
+ if (!test_truncated_json())
+ nfail++;
+ if (!test_unclosed_brace())
+ nfail++;
+ if (nfail) {
+ fprintf(stderr, "%d failed\n", nfail);
+ return 1;
+ }
+ printf("test_json_baseline: OK\n");
+ return 0;
+}
diff --git a/tests/test_mm_tool.c b/tests/test_mm_tool.c
new file mode 100644
index 0000000..c3ee301
--- /dev/null
+++ b/tests/test_mm_tool.c
@@ -0,0 +1,65 @@
+#include "mm_tool.h"
+
+#include
+#include
+
+typedef struct {
+ int n_ready;
+ int n_text;
+ char name[64];
+ char path[256];
+} sink_t;
+
+static void on_ev(const ctl_event_t *ev, void *ud) {
+ sink_t *s = ud;
+ if (ev->type == CTL_EV_TEXT)
+ s->n_text++;
+ if (ev->type == CTL_EV_CALL_READY && ev->call) {
+ s->n_ready++;
+ snprintf(s->name, sizeof(s->name), "%s", ev->call->name);
+ if (ev->call->nargs > 0 && ev->call->args[0].value)
+ snprintf(s->path, sizeof(s->path), "%s", ev->call->args[0].value);
+ }
+}
+
+static int expect(int c, const char *m) {
+ if (!c) {
+ fprintf(stderr, "FAIL: %s\n", m);
+ return 0;
+ }
+ return 1;
+}
+
+int main(void) {
+ sink_t s = {0};
+ mm_scan_t *mm = mm_scan_create(on_ev, &s);
+ const char *msg =
+ "我来读一下。\n"
+ "\n"
+ "invoke read_file\n"
+ "./README.md \n"
+ " \n"
+ "读完了。\n";
+ /* byte-at-a-time */
+ for (size_t i = 0; msg[i]; i++)
+ mm_scan_feed(mm, &msg[i], 1);
+ mm_scan_finish(mm);
+ int ok = 1;
+ ok &= expect(s.n_ready == 1, "one ready");
+ ok &= expect(strcmp(s.name, "read_file") == 0, "name");
+ ok &= expect(strcmp(s.path, "./README.md") == 0, "path");
+ ok &= expect(s.n_text >= 1, "text emitted");
+ /* truncated: no execute */
+ sink_t s2 = {0};
+ mm_scan_t *mm2 = mm_scan_create(on_ev, &s2);
+ const char *trunc = "\ninvoke read_file\n";
+ mm_scan_feed(mm2, trunc, strlen(trunc));
+ mm_scan_finish(mm2);
+ ok &= expect(s2.n_ready == 0, "trunc no ready");
+ mm_scan_destroy(mm);
+ mm_scan_destroy(mm2);
+ if (!ok)
+ return 1;
+ printf("test_mm_tool: OK\n");
+ return 0;
+}
diff --git a/tests/test_replay_starvation.c b/tests/test_replay_starvation.c
new file mode 100644
index 0000000..43847fd
--- /dev/null
+++ b/tests/test_replay_starvation.c
@@ -0,0 +1,102 @@
+#include "ctl.h"
+#include "cst_agent.h"
+
+#include
+#include
+#include
+
+/*
+ * Starvation replay: same logical tool call, cut at several prefixes.
+ * CTL+CST should never execute on incomplete prefixes; JSON-buffer fails to
+ * parse (cannot execute). When the full stream is present, both succeed.
+ */
+
+typedef struct {
+ int n_ready;
+ size_t peak;
+} ctl_stats_t;
+
+static void on_ev(const ctl_event_t *ev, void *ud) {
+ ctl_stats_t *s = ud;
+ if (ev->type == CTL_EV_CALL_READY)
+ s->n_ready++;
+}
+
+static int ctl_ready_on_prefix(const char *full, size_t ncut, size_t *peak) {
+ ctl_stats_t st = {0};
+ ctl_parser_t *p = ctl_parser_create(on_ev, &st);
+ ctl_parser_feed(p, full, ncut);
+ /* do not finish — simulate stream stall / truncation mid-turn */
+ *peak = ctl_parser_pending_peak(p);
+ int ready = st.n_ready;
+ ctl_parser_destroy(p);
+ return ready;
+}
+
+int main(void) {
+ const char *ctl =
+ "call x1 read_file\n"
+ "arg path 5\n"
+ "a.txt"
+ "end\n";
+ const char *json =
+ "{\"name\":\"read_file\",\"arguments\":{\"path\":\"a.txt\"}}";
+
+ size_t ctl_len = strlen(ctl);
+ size_t json_len = strlen(json);
+
+ int ctl_premature = 0, json_ok_full = 0, ctl_ok_full = 0;
+ int json_fail_trunc = 0, ctl_safe_trunc = 0;
+
+ /* full streams */
+ {
+ size_t peak = 0;
+ ctl_stats_t st = {0};
+ ctl_parser_t *p = ctl_parser_create(on_ev, &st);
+ ctl_parser_feed(p, ctl, ctl_len);
+ ctl_parser_finish(p);
+ peak = ctl_parser_pending_peak(p);
+ ctl_ok_full = (st.n_ready == 1);
+ printf("CTL full: ready=%d peak=%zu\n", st.n_ready, peak);
+ ctl_parser_destroy(p);
+ }
+ {
+ cst_json_tool_t t;
+ json_ok_full = cst_json_try_parse_tool(json, json_len, &t);
+ printf("JSON full: ok=%d name=%s\n", json_ok_full, t.name);
+ }
+
+ /* truncate at 50% and 80% */
+ size_t cuts[] = {ctl_len / 2, (ctl_len * 4) / 5, ctl_len - 2};
+ for (size_t i = 0; i < sizeof(cuts) / sizeof(cuts[0]); i++) {
+ size_t peak = 0;
+ int ready = ctl_ready_on_prefix(ctl, cuts[i], &peak);
+ printf("CTL cut %zu/%zu: ready=%d peak=%zu\n", cuts[i], ctl_len, ready,
+ peak);
+ if (ready)
+ ctl_premature++;
+ else
+ ctl_safe_trunc++;
+ }
+
+ size_t jcuts[] = {json_len / 2, (json_len * 4) / 5, json_len - 2};
+ for (size_t i = 0; i < sizeof(jcuts) / sizeof(jcuts[0]); i++) {
+ cst_json_tool_t t;
+ int ok = cst_json_try_parse_tool(json, jcuts[i], &t);
+ printf("JSON cut %zu/%zu: ok=%d\n", jcuts[i], json_len, ok);
+ if (!ok)
+ json_fail_trunc++;
+ }
+
+ printf("\n=== summary ===\n");
+ printf("CTL full success: %s\n", ctl_ok_full ? "yes" : "no");
+ printf("JSON full success: %s\n", json_ok_full ? "yes" : "no");
+ printf("CTL premature executes on trunc: %d (want 0)\n", ctl_premature);
+ printf("CTL safe truncations: %d\n", ctl_safe_trunc);
+ printf("JSON trunc failures: %d\n", json_fail_trunc);
+
+ if (!ctl_ok_full || !json_ok_full || ctl_premature != 0)
+ return 1;
+ printf("replay_starvation: OK\n");
+ return 0;
+}
diff --git a/third_party/busybox/LICENSE.busybox b/third_party/busybox/LICENSE.busybox
new file mode 100644
index 0000000..aa3aaca
--- /dev/null
+++ b/third_party/busybox/LICENSE.busybox
@@ -0,0 +1,2 @@
+BusyBox is GPLv2. Source: https://github.com/rmyorston/busybox-w32
+Upstream BusyBox: https://github.com/mirror/busybox
diff --git a/third_party/busybox/README.md b/third_party/busybox/README.md
new file mode 100644
index 0000000..ca5c16e
--- /dev/null
+++ b/third_party/busybox/README.md
@@ -0,0 +1,26 @@
+# BusyBox for CST-Agent
+
+## Windows (`busybox.exe`)
+
+Built from **busybox-w32**:
+https://github.com/rmyorston/busybox-w32.git
+
+```bash
+./scripts/build_third_party_mingw.sh busybox-w32
+# → third_party/busybox/busybox.exe
+```
+
+Ship next to `cst-agent.exe`. License: GPLv2 (`LICENSE.busybox`).
+
+## Linux / Termux (optional side-by-side `busybox`)
+
+Upstream mirror:
+https://github.com/mirror/busybox.git
+
+```bash
+./scripts/build_third_party_mingw.sh busybox-unix
+# → third_party/busybox/busybox
+```
+
+On Unix, `run_shell` prefers `CST_SHELL`, then `/busybox`, then
+`$PREFIX/bin/sh` (Termux), then `/bin/sh`.
diff --git a/vendor/sashidown/Makefile b/vendor/sashidown/Makefile
new file mode 100644
index 0000000..ad3c3c9
--- /dev/null
+++ b/vendor/sashidown/Makefile
@@ -0,0 +1,16 @@
+CC ?= gcc
+CFLAGS ?= -std=c11 -Wall -Wextra -O2 -Iinclude -Isrc
+SRCS = src/fsm.c src/table_stats.c src/line_index.c src/block_scan.c \
+ src/ansi_style.c src/render.c src/render_ansi.c src/render_json.c \
+ src/sashidown.c src/compat_cmark.c src/stream.c
+OBJS = $(SRCS:.c=.o)
+LIB = libsashidown.a
+
+.PHONY: all clean
+all: $(LIB)
+$(LIB): $(OBJS)
+ $(AR) rcs $@ $(OBJS)
+src/%.o: src/%.c
+ $(CC) $(CFLAGS) -c -o $@ $<
+clean:
+ rm -f $(OBJS) $(LIB)
diff --git a/vendor/sashidown/include/sashidown.h b/vendor/sashidown/include/sashidown.h
new file mode 100644
index 0000000..f43c1f9
--- /dev/null
+++ b/vendor/sashidown/include/sashidown.h
@@ -0,0 +1,64 @@
+#ifndef SASHIDOWN_H
+#define SASHIDOWN_H
+
+#include
+
+#define SASHIDOWN_OUTPUT_DEFAULT 65536
+
+typedef struct {
+ const char *input;
+ size_t input_len;
+ char *output;
+ size_t output_cap;
+ size_t output_len;
+ char *write_ptr;
+ char *write_end;
+ void *line_ix;
+ int overflow; /* set when output buffer is exhausted */
+} sashidown_ctx_t;
+
+/* Parse markdown to HTML. Returns 0 on success, -1 on error
+ * (null args, or output buffer too small — never silently truncates). */
+int sashidown_parse(const char *input, size_t len,
+ char *output, size_t cap, size_t *out_len);
+
+typedef enum {
+ SASHIDOWN_FMT_HTML = 0,
+ SASHIDOWN_FMT_ANSI = 1, /* built-in colorful terminal theme */
+ SASHIDOWN_FMT_JSON = 2,
+ SASHIDOWN_FMT_ANSI_PLAIN = 3, /* monochrome / SGR attributes only */
+} sashidown_format_t;
+
+/* Single-pass convert. SASHIDOWN_FMT_HTML delegates to sashidown_parse.
+ * Returns 0 on success, -1 on error (null args, bad format, or buffer too small). */
+int sashidown_convert(const char *input, size_t len,
+ char *output, size_t cap, size_t *out_len,
+ sashidown_format_t format);
+
+/* Allocating wrapper (thread-safe). Caller must free via sashidown_free. */
+char *cmark_markdown_to_html(const char *text, int len);
+void sashidown_free(void *ptr);
+
+/* --- Streaming API (AI conversation / incremental input) --- */
+
+typedef struct sashidown_stream sashidown_stream_t;
+
+/* Called for each HTML chunk; return non-zero to abort feeding. */
+typedef int (*sashidown_stream_cb)(const char *html, size_t len, void *user);
+
+typedef struct {
+ sashidown_stream_cb on_html;
+ void *user;
+ size_t output_buf_size; /* default 8192 if 0; grows on overflow */
+} sashidown_stream_opts_t;
+
+sashidown_stream_t *sashidown_stream_create(const sashidown_stream_opts_t *opts);
+int sashidown_stream_feed(sashidown_stream_t *s, const char *chunk, size_t len);
+int sashidown_stream_finish(sashidown_stream_t *s);
+void sashidown_stream_destroy(sashidown_stream_t *s);
+
+/* Bytes held past the confirmation frontier (input_len - render_pos).
+ * Unconfirmed suffix under uncertain future input; O(1) query. */
+size_t sashidown_stream_pending(const sashidown_stream_t *s);
+
+#endif /* SASHIDOWN_H */
diff --git a/vendor/sashidown/src/ansi_style.c b/vendor/sashidown/src/ansi_style.c
new file mode 100644
index 0000000..eaffb8d
--- /dev/null
+++ b/vendor/sashidown/src/ansi_style.c
@@ -0,0 +1,104 @@
+#include "ansi_style.h"
+#include
+
+/* Default colorful theme — 16-color SGR, widely supported. */
+static const ansi_theme_t THEME_COLOR = {
+ .reset = "\033[0m",
+
+ .heading = {
+ NULL,
+ "\033[1;96m", /* h1 bright cyan */
+ "\033[1;94m", /* h2 bright blue */
+ "\033[1;92m", /* h3 bright green */
+ "\033[1;95m", /* h4 bright magenta */
+ "\033[1;93m", /* h5 bright yellow */
+ "\033[1;91m", /* h6 bright red */
+ },
+ .heading_end = "\033[0m",
+
+ .strong_on = "\033[1m",
+ .strong_off = "\033[22m",
+ .emph_on = "\033[3;33m", /* italic yellow */
+ .emph_off = "\033[23;39m",
+ .strike_on = "\033[9m",
+ .strike_off = "\033[29m",
+
+ .code_on = "\033[1;36m", /* bright cyan */
+ .code_off = "\033[22;39m",
+ .fence_on = "\033[32m", /* green */
+ .fence_off = "\033[0m",
+
+ .link_on = "\033[4;94m", /* underline bright blue */
+ .link_off = "\033[24;39m",
+ .url_on = "\033[2;36m", /* dim cyan */
+ .url_off = "\033[22;39m",
+ .image_on = "\033[95m",
+ .image_off = "\033[39m",
+
+ .bullet = "\033[93m• \033[0m",
+ .bullet_end = "",
+ .ol_on = "\033[93m",
+ .ol_off = "\033[0m",
+
+ .bq_prefix = "\033[2;35m│ \033[0m",
+ .th_on = "\033[1;96m",
+ .th_off = "\033[0m",
+ .sep = "\033[2m │ \033[0m",
+
+ .latex_on = "\033[95m",
+ .latex_off = "\033[39m",
+};
+
+/* Monochrome / attribute-only (previous behavior). */
+static const ansi_theme_t THEME_PLAIN = {
+ .reset = "\033[0m",
+
+ .heading = {
+ NULL,
+ "\033[1m", "\033[1m", "\033[1m",
+ "\033[1m", "\033[1m", "\033[1m",
+ },
+ .heading_end = "\033[0m",
+
+ .strong_on = "\033[1m",
+ .strong_off = "\033[22m",
+ .emph_on = "\033[3m",
+ .emph_off = "\033[23m",
+ .strike_on = "\033[9m",
+ .strike_off = "\033[29m",
+
+ .code_on = "\033[7m",
+ .code_off = "\033[27m",
+ .fence_on = "\033[2m",
+ .fence_off = "\033[0m",
+
+ .link_on = "",
+ .link_off = "",
+ .url_on = "",
+ .url_off = "",
+ .image_on = "",
+ .image_off = "",
+
+ .bullet = "• ",
+ .bullet_end = "",
+ .ol_on = "",
+ .ol_off = "",
+
+ .bq_prefix = "\033[2m| \033[0m",
+ .th_on = "\033[1m",
+ .th_off = "\033[22m",
+ .sep = " | ",
+
+ .latex_on = "",
+ .latex_off = "",
+};
+
+const ansi_theme_t *ansi_theme_color(void)
+{
+ return &THEME_COLOR;
+}
+
+const ansi_theme_t *ansi_theme_plain(void)
+{
+ return &THEME_PLAIN;
+}
diff --git a/vendor/sashidown/src/ansi_style.h b/vendor/sashidown/src/ansi_style.h
new file mode 100644
index 0000000..5c9e7dd
--- /dev/null
+++ b/vendor/sashidown/src/ansi_style.h
@@ -0,0 +1,48 @@
+#ifndef SASHIDOWN_ANSI_STYLE_H
+#define SASHIDOWN_ANSI_STYLE_H
+
+/* Built-in ANSI role styles (SGR). Strings are compile-time literals. */
+
+typedef struct {
+ const char *reset;
+
+ const char *heading[7]; /* [1..6] used; [0] unused */
+ const char *heading_end;
+
+ const char *strong_on;
+ const char *strong_off;
+ const char *emph_on;
+ const char *emph_off;
+ const char *strike_on;
+ const char *strike_off;
+
+ const char *code_on;
+ const char *code_off;
+ const char *fence_on;
+ const char *fence_off;
+
+ const char *link_on;
+ const char *link_off;
+ const char *url_on;
+ const char *url_off;
+ const char *image_on;
+ const char *image_off;
+
+ const char *bullet;
+ const char *bullet_end;
+ const char *ol_on;
+ const char *ol_off;
+
+ const char *bq_prefix;
+ const char *th_on;
+ const char *th_off;
+ const char *sep; /* table column separator */
+
+ const char *latex_on;
+ const char *latex_off;
+} ansi_theme_t;
+
+const ansi_theme_t *ansi_theme_color(void);
+const ansi_theme_t *ansi_theme_plain(void);
+
+#endif
diff --git a/vendor/sashidown/src/block_scan.c b/vendor/sashidown/src/block_scan.c
new file mode 100644
index 0000000..c03944c
--- /dev/null
+++ b/vendor/sashidown/src/block_scan.c
@@ -0,0 +1,337 @@
+#include "block_scan.h"
+#include
+
+int block_count_heading(const char *input, size_t len, size_t pos, size_t *content_start)
+{
+ size_t i = pos;
+ int level = 0;
+ while (i < len && input[i] == '#') {
+ level++;
+ i++;
+ }
+ if (level == 0 || level > 6) return 0;
+ if (i < len && input[i] != ' ' && input[i] != '\n') return 0;
+ while (i < len && input[i] == ' ') i++;
+ *content_start = i;
+ return level;
+}
+
+int block_is_fence(const char *input, size_t len, size_t pos,
+ int *fence_len, char *fence_char)
+{
+ size_t i = pos;
+ while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
+ if (i + 2 >= len) return 0;
+ char ch = input[i];
+ if (ch != '`' && ch != '~') return 0;
+ if (input[i + 1] != ch || input[i + 2] != ch) return 0;
+ int fl = 3;
+ i += 3;
+ while (i < len && input[i] == ch) { fl++; i++; }
+ *fence_len = fl;
+ if (fence_char)
+ *fence_char = ch;
+ return 1;
+}
+
+void block_fence_info_range(const char *input, size_t len, size_t pos,
+ int fence_len, char fence_char,
+ size_t *info_start, size_t *info_end)
+{
+ size_t i = pos;
+ while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
+ i += (size_t)fence_len;
+ (void)fence_char;
+ while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
+ *info_start = i;
+ size_t end = i;
+ while (end < len && input[end] != '\n' && input[end] != '\r')
+ end++;
+ while (end > *info_start &&
+ (input[end - 1] == ' ' || input[end - 1] == '\t'))
+ end--;
+ *info_end = end;
+}
+
+int block_is_thematic_break(const char *input, size_t pos, size_t le)
+{
+ size_t i = pos;
+ while (i < le && (input[i] == ' ' || input[i] == '\t')) i++;
+ if (i >= le) return 0;
+
+ char marker = input[i];
+ if (marker != '*' && marker != '-' && marker != '_') return 0;
+
+ int count = 0;
+ for (; i < le; i++) {
+ char c = input[i];
+ if (c == marker) {
+ count++;
+ } else if (c == ' ' || c == '\t') {
+ continue;
+ } else if (c == '\r') {
+ continue;
+ } else {
+ return 0;
+ }
+ }
+ return count >= 3;
+}
+
+int block_is_setext_underline(const char *input, size_t pos, size_t le, int *level)
+{
+ size_t i = pos;
+ while (i < le && (input[i] == ' ' || input[i] == '\t')) i++;
+ if (i >= le) return 0;
+
+ char marker = input[i];
+ if (marker != '=' && marker != '-') return 0;
+
+ size_t start = i;
+ while (i < le && input[i] == marker) i++;
+ if (i == start) return 0;
+ while (i < le && (input[i] == ' ' || input[i] == '\t' || input[i] == '\r'))
+ i++;
+ if (i < le) return 0;
+
+ /* Thematic-break-looking '---' is still a valid setext underline when
+ * paired with a preceding text line (caller decides context). */
+ if (level)
+ *level = (marker == '=') ? 1 : 2;
+ return 1;
+}
+
+int block_is_blockquote(const char *input, size_t len, size_t pos, size_t *content_start)
+{
+ size_t i = pos;
+ while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
+ if (i >= len || input[i] != '>') return 0;
+ i++;
+ if (i < len && input[i] == ' ') i++;
+ *content_start = i;
+ return 1;
+}
+
+int block_is_ul(const char *input, size_t len, size_t pos, size_t *content_start)
+{
+ size_t i = pos;
+ while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
+ if (i >= len) return 0;
+ if (input[i] != '-' && input[i] != '*' && input[i] != '+') return 0;
+ if (i + 1 < len && input[i + 1] != ' ') return 0;
+ i += 2;
+ *content_start = i;
+ return 1;
+}
+
+int block_parse_task_marker(const char *input, size_t len, size_t content_start,
+ int *task, size_t *text_start)
+{
+ size_t i = content_start;
+ if (i + 3 > len) return 0;
+ if (input[i] != '[') return 0;
+ char mark = input[i + 1];
+ if (input[i + 2] != ']') return 0;
+ if (mark != ' ' && mark != 'x' && mark != 'X') return 0;
+ i += 3;
+ if (i < len && input[i] == ' ') i++;
+ *task = (mark == ' ') ? 1 : 2;
+ *text_start = i;
+ return 1;
+}
+
+int block_is_ol(const char *input, size_t len, size_t pos, size_t *content_start, int *num)
+{
+ size_t i = pos;
+ while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
+ size_t num_start = i;
+ { unsigned char c = (unsigned char)input[i];
+ if (i >= len || c < '0' || c > '9') return 0; }
+ while (i < len) {
+ unsigned char c = (unsigned char)input[i];
+ if (c < '0' || c > '9') break;
+ i++;
+ }
+ if (i >= len || input[i] != '.') return 0;
+ if (i + 1 < len && input[i + 1] != ' ') return 0;
+ *num = 0;
+ for (size_t j = num_start; j < i; j++)
+ *num = *num * 10 + (input[j] - '0');
+ i += 2;
+ *content_start = i;
+ return 1;
+}
+
+int block_line_starts_table(const char *input, size_t len, size_t pos)
+{
+ size_t i = pos;
+ while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
+ if (i >= len) return 0;
+ /* GFM: leading '|' OR a pipe somewhere on the line with a separator below.
+ * Cheap reject: must contain '|' before newline. */
+ const char *nl = memchr(input + i, '\n', len - i);
+ size_t line_len = nl ? (size_t)(nl - (input + i)) : (len - i);
+ return memchr(input + i, '|', line_len) != NULL;
+}
+
+int block_is_latex_line(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t pos, size_t *content_start, size_t *content_end)
+{
+ size_t i = pos;
+ while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
+ if (i + 1 >= len || input[i] != '$' || input[i + 1] != '$') return 0;
+ i += 2;
+ *content_start = i;
+ size_t le = block_line_end(ctx, input, len, pos);
+ if (i + 1 < le && input[le - 2] == '$' && input[le - 1] == '$') {
+ *content_end = le - 2;
+ return 1;
+ }
+ return 0;
+}
+
+int block_try_setext(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t pos, size_t le, size_t stop, int is_final,
+ line_info_t *info, size_t *end_pos)
+{
+ if (pos == le || (pos + 1 == le && input[pos] == '\r'))
+ return 0;
+
+ /* Next line starts after the newline at `le` (line_end points at '\n'). */
+ if (le >= len || input[le] != '\n')
+ return is_final ? 0 : -1;
+
+ size_t next = le + 1;
+ if (next >= len)
+ return is_final ? 0 : -1;
+ if (next >= stop && !is_final)
+ return -1;
+
+ /* Cheap reject before full underline scan. */
+ size_t u = next;
+ while (u < len && (input[u] == ' ' || input[u] == '\t'))
+ u++;
+ if (u >= len || (input[u] != '=' && input[u] != '-'))
+ return 0;
+
+ size_t nle = block_line_end(ctx, input, len, next);
+ int level = 0;
+ if (!block_is_setext_underline(input, next, nle, &level))
+ return 0;
+
+ info->kind = LK_HEADING;
+ info->heading_level = level;
+ info->content_start = pos;
+ while (info->content_start < le &&
+ (input[info->content_start] == ' ' || input[info->content_start] == '\t'))
+ info->content_start++;
+ info->content_end = le;
+ while (info->content_end > info->content_start &&
+ (input[info->content_end - 1] == ' ' ||
+ input[info->content_end - 1] == '\t' ||
+ input[info->content_end - 1] == '\r'))
+ info->content_end--;
+ info->fence_len = 0;
+ info->fence_char = 0;
+ info->info_start = 0;
+ info->info_end = 0;
+ info->task = 0;
+ *end_pos = block_skip_line(ctx, input, len, next);
+ return 1;
+}
+
+line_info_t block_classify_line(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t pos, size_t le)
+{
+ line_info_t info;
+ memset(&info, 0, sizeof(info));
+ info.kind = LK_PARA;
+ info.content_start = pos;
+ info.content_end = le;
+
+ if (pos == le || (pos + 1 == le && input[pos] == '\r')) {
+ info.kind = LK_EMPTY;
+ return info;
+ }
+
+ size_t i = pos;
+ while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
+ unsigned char first = (i < len) ? (unsigned char)input[i] : 0;
+
+ /* Thematic breaks only possible for - / * / _ lines. */
+ if ((first == '*' || first == '-' || first == '_') &&
+ block_is_thematic_break(input, pos, le)) {
+ info.kind = LK_HR;
+ return info;
+ }
+
+ switch (first) {
+ case '#':
+ info.heading_level = block_count_heading(input, len, pos, &info.content_start);
+ if (info.heading_level > 0) {
+ info.kind = LK_HEADING;
+ info.content_end = le;
+ return info;
+ }
+ break;
+ case '`':
+ case '~':
+ if (block_is_fence(input, len, pos, &info.fence_len, &info.fence_char)) {
+ info.kind = LK_FENCE;
+ block_fence_info_range(input, len, pos, info.fence_len, info.fence_char,
+ &info.info_start, &info.info_end);
+ return info;
+ }
+ break;
+ case '>':
+ if (block_is_blockquote(input, len, pos, &info.content_start)) {
+ info.kind = LK_BQ;
+ return info;
+ }
+ break;
+ case '-': case '*': case '+':
+ if (block_is_ul(input, len, pos, &info.content_start)) {
+ info.kind = LK_UL;
+ size_t text = info.content_start;
+ int task = 0;
+ if (block_parse_task_marker(input, le, info.content_start, &task, &text)) {
+ info.task = task;
+ info.content_start = text;
+ }
+ return info;
+ }
+ break;
+ case '0': case '1': case '2': case '3': case '4':
+ case '5': case '6': case '7': case '8': case '9':
+ {
+ int ol_num = 0;
+ if (block_is_ol(input, len, pos, &info.content_start, &ol_num)) {
+ info.kind = LK_OL;
+ return info;
+ }
+ break;
+ }
+ case '$':
+ {
+ size_t cs, ce;
+ if (i + 1 < len && input[i] == '$' && input[i + 1] == '$') {
+ if (i == pos) {
+ info.kind = LK_LATEX_BLOCK;
+ return info;
+ }
+ if (block_is_latex_line(ctx, input, len, pos, &cs, &ce)) {
+ info.kind = LK_LATEX_LINE;
+ info.content_start = cs;
+ info.content_end = ce;
+ return info;
+ }
+ }
+ break;
+ }
+ default:
+ break;
+ }
+
+ info.kind = LK_PARA;
+ return info;
+}
diff --git a/vendor/sashidown/src/block_scan.h b/vendor/sashidown/src/block_scan.h
new file mode 100644
index 0000000..8c06ece
--- /dev/null
+++ b/vendor/sashidown/src/block_scan.h
@@ -0,0 +1,196 @@
+#ifndef SASHIDOWN_BLOCK_SCAN_H
+#define SASHIDOWN_BLOCK_SCAN_H
+
+#include "sashidown.h"
+#include "render.h"
+#include "fsm.h"
+#include "line_index.h"
+#include
+#include
+#include
+
+#ifdef __SSE2__
+#include
+#elif defined(__ARM_NEON) || defined(__aarch64__)
+#include
+#endif
+
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Woverride-init"
+static inline size_t block_utf8_seq_len(unsigned char ch)
+{
+ static const unsigned char tbl[256] = {
+ [0x00 ... 0xFF] = 1,
+ [0xC0 ... 0xDF] = 2,
+ [0xE0 ... 0xEF] = 3,
+ [0xF0 ... 0xF7] = 4,
+ };
+ return tbl[ch];
+}
+#pragma GCC diagnostic pop
+
+static inline size_t block_scan_plain_ascii(const char *restrict input, size_t end, size_t i)
+{
+#ifdef __SSE2__
+ const __m128i m_star = _mm_set1_epi8('*');
+ const __m128i m_us = _mm_set1_epi8('_');
+ const __m128i m_tick = _mm_set1_epi8('`');
+ const __m128i m_dol = _mm_set1_epi8('$');
+ const __m128i m_bang = _mm_set1_epi8('!');
+ const __m128i m_lb = _mm_set1_epi8('[');
+ const __m128i m_amp = _mm_set1_epi8('&');
+ const __m128i m_lt = _mm_set1_epi8('<');
+ const __m128i m_gt = _mm_set1_epi8('>');
+ const __m128i m_bs = _mm_set1_epi8('\\');
+ const __m128i m_tilde = _mm_set1_epi8('~');
+ while (i + 16 <= end) {
+ __m128i v = _mm_loadu_si128((const __m128i *)(input + i));
+ if (_mm_movemask_epi8(v))
+ break; /* high bit => UTF-8 */
+ __m128i hit = _mm_cmpeq_epi8(v, m_star);
+ hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_us));
+ hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_tick));
+ hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_dol));
+ hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_bang));
+ hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_lb));
+ hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_amp));
+ hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_lt));
+ hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_gt));
+ hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_bs));
+ hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_tilde));
+ int mask = _mm_movemask_epi8(hit);
+ if (mask)
+ return i + (unsigned)__builtin_ctz(mask);
+ i += 16;
+ }
+#elif defined(__ARM_NEON) || defined(__aarch64__)
+ while (i + 16 <= end) {
+ uint8x16_t v = vld1q_u8((const uint8_t *)(input + i));
+ if (vmaxvq_u8(vandq_u8(v, vdupq_n_u8(0x80))))
+ break;
+ uint8x16_t hit = vceqq_u8(v, vdupq_n_u8('*'));
+ hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('_')));
+ hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('`')));
+ hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('$')));
+ hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('!')));
+ hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('[')));
+ hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('&')));
+ hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('<')));
+ hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('>')));
+ hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('\\')));
+ hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('~')));
+ if (vmaxvq_u8(hit)) {
+ uint8_t lanes[16];
+ vst1q_u8(lanes, hit);
+ size_t pos = 0;
+ while (pos < 16 && !lanes[pos]) pos++;
+ return i + pos;
+ }
+ i += 16;
+ }
+#endif
+ size_t j = i;
+ while (j + 4 <= end) {
+ uint32_t v;
+ memcpy(&v, input + j, 4);
+ if (v & 0x80808080)
+ break;
+ if (CH_SCAN_STOP[(v & 0xFF)] ||
+ CH_SCAN_STOP[(v >> 8) & 0xFF] ||
+ CH_SCAN_STOP[(v >> 16) & 0xFF] ||
+ CH_SCAN_STOP[(v >> 24) & 0xFF])
+ break;
+ j += 4;
+ }
+ while (j < end) {
+ unsigned char c = (unsigned char)input[j];
+ if ((c & 0x80))
+ break;
+ if (unlikely(CH_SCAN_STOP[c]))
+ break;
+ j++;
+ }
+ return j;
+}
+
+static inline size_t block_scan_utf8_run(const char *input, size_t end, size_t i)
+{
+ size_t j = i;
+ while (j < end) {
+ unsigned char c = (unsigned char)input[j];
+ if (likely((c & 0x80) == 0))
+ break;
+ size_t seq = block_utf8_seq_len(c);
+ if (j + seq > end)
+ break;
+ j += seq;
+ }
+ return j;
+}
+
+static inline size_t block_scan_until_byte(const char *input, size_t end, size_t i, char delim)
+{
+ if (i >= end)
+ return i;
+ const char *hit = memchr(input + i, delim, end - i);
+ return hit ? (size_t)(hit - input) : end;
+}
+
+static inline size_t block_scan_until_2byte(const char *input, size_t end, size_t start,
+ char c1, char c2)
+{
+ size_t i = start;
+ while (i + 1 < end) {
+ const char *hit = memchr(input + i, c1, end - i);
+ if (!hit) return end;
+ i = (size_t)(hit - input);
+ if (i + 1 < end && input[i + 1] == c2)
+ return i;
+ i++;
+ }
+ return end;
+}
+
+static inline size_t block_line_end(sashidown_ctx_t *ctx, const char *input, size_t len, size_t pos)
+{
+ return line_end_ix((line_index_t *)ctx->line_ix, input, len, pos);
+}
+
+static inline size_t block_skip_line(sashidown_ctx_t *ctx, const char *input, size_t len, size_t pos)
+{
+ return skip_line_ix((line_index_t *)ctx->line_ix, input, len, pos);
+}
+
+static inline void block_line_seek(sashidown_ctx_t *ctx, size_t pos)
+{
+ line_index_seek((line_index_t *)ctx->line_ix, pos);
+}
+
+int block_count_heading(const char *input, size_t len, size_t pos, size_t *content_start);
+int block_is_fence(const char *input, size_t len, size_t pos,
+ int *fence_len, char *fence_char);
+void block_fence_info_range(const char *input, size_t len, size_t pos,
+ int fence_len, char fence_char,
+ size_t *info_start, size_t *info_end);
+int block_is_thematic_break(const char *input, size_t pos, size_t le);
+int block_is_setext_underline(const char *input, size_t pos, size_t le, int *level);
+int block_is_blockquote(const char *input, size_t len, size_t pos, size_t *content_start);
+int block_is_ul(const char *input, size_t len, size_t pos, size_t *content_start);
+int block_is_ol(const char *input, size_t len, size_t pos, size_t *content_start, int *num);
+int block_parse_task_marker(const char *input, size_t len, size_t content_start,
+ int *task, size_t *text_start);
+int block_line_starts_table(const char *input, size_t len, size_t pos);
+int block_is_latex_line(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t pos, size_t *content_start, size_t *content_end);
+
+/* Peek next-line setext underline. Does NOT advance line_index on failure.
+ * `le` must be block_line_end(pos). Caller should only invoke for LK_PARA lines.
+ * Returns 1 = heading, 0 = no, -1 = defer (need next line). */
+int block_try_setext(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t pos, size_t le, size_t stop, int is_final,
+ line_info_t *info, size_t *end_pos);
+
+line_info_t block_classify_line(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t pos, size_t le);
+
+#endif /* SASHIDOWN_BLOCK_SCAN_H */
diff --git a/vendor/sashidown/src/compat_cmark.c b/vendor/sashidown/src/compat_cmark.c
new file mode 100644
index 0000000..a38a137
--- /dev/null
+++ b/vendor/sashidown/src/compat_cmark.c
@@ -0,0 +1,44 @@
+#include "sashidown.h"
+#include
+#include
+#include
+
+char *cmark_markdown_to_html(const char *text, int len)
+{
+ if (!text)
+ return NULL;
+ if (len < 0)
+ len = (int)strlen(text);
+
+ size_t in_len = (size_t)len;
+ /* HTML typically expands; grow until parse fits (thread-safe, no static buf). */
+ size_t cap = in_len * 4 + 256;
+ if (cap < 1024)
+ cap = 1024;
+
+ for (;;) {
+ char *buf = (char *)malloc(cap);
+ if (!buf)
+ return NULL;
+
+ size_t out_len = 0;
+ if (sashidown_parse(text, in_len, buf, cap, &out_len) == 0) {
+ char *fit = (char *)realloc(buf, out_len + 1);
+ return fit ? fit : buf;
+ }
+
+ free(buf);
+ if (cap > SIZE_MAX / 2)
+ return NULL;
+ size_t next = cap * 2;
+ /* Bail if expansion is absurd relative to input. */
+ if (next > in_len * 64 + 4096)
+ return NULL;
+ cap = next;
+ }
+}
+
+void sashidown_free(void *ptr)
+{
+ free(ptr);
+}
diff --git a/vendor/sashidown/src/fsm.c b/vendor/sashidown/src/fsm.c
new file mode 100644
index 0000000..57853ba
--- /dev/null
+++ b/vendor/sashidown/src/fsm.c
@@ -0,0 +1,110 @@
+#include "fsm.h"
+#include
+
+const uint8_t CH_HTML_ESCAPE[256] = {
+ ['&'] = 1, ['<'] = 1, ['>'] = 1, ['"'] = 1,
+};
+
+const uint8_t CH_JSON_ESCAPE[256] = {
+ ['"'] = 1, ['\\'] = 1,
+ [0x00] = 1, [0x01] = 1, [0x02] = 1, [0x03] = 1,
+ [0x04] = 1, [0x05] = 1, [0x06] = 1, [0x07] = 1,
+ [0x08] = 1, [0x09] = 1, [0x0A] = 1, [0x0B] = 1,
+ [0x0C] = 1, [0x0D] = 1, [0x0E] = 1, [0x0F] = 1,
+ [0x10] = 1, [0x11] = 1, [0x12] = 1, [0x13] = 1,
+ [0x14] = 1, [0x15] = 1, [0x16] = 1, [0x17] = 1,
+ [0x18] = 1, [0x19] = 1, [0x1A] = 1, [0x1B] = 1,
+ [0x1C] = 1, [0x1D] = 1, [0x1E] = 1, [0x1F] = 1,
+};
+
+/* Inline special + HTML escape; combine with (c & 0x80) in the scan loop. */
+const uint8_t CH_SCAN_STOP[256] = {
+ ['*'] = 1, ['_'] = 1, ['`'] = 1, ['$'] = 1, ['!'] = 1, ['['] = 1,
+ ['&'] = 1, ['<'] = 1, ['>'] = 1, ['\\'] = 1, ['~'] = 1,
+};
+
+void fsm_write_begin(sashidown_ctx_t *ctx)
+{
+ ctx->overflow = 0;
+ ctx->write_ptr = ctx->output + ctx->output_len;
+ ctx->write_end = ctx->output + ctx->output_cap - 1;
+}
+
+void fsm_write_end(sashidown_ctx_t *ctx)
+{
+ if (ctx->write_ptr)
+ ctx->output_len = (size_t)(ctx->write_ptr - ctx->output);
+ ctx->write_ptr = NULL;
+ ctx->write_end = NULL;
+}
+
+int fsm_append(sashidown_ctx_t *ctx, char c)
+{
+ if (unlikely(ctx->overflow))
+ return -1;
+ if (ctx->write_ptr) {
+ if (unlikely(ctx->write_ptr >= ctx->write_end)) {
+ ctx->overflow = 1;
+ return -1;
+ }
+ *ctx->write_ptr++ = c;
+ return 0;
+ }
+ if (unlikely(ctx->output_len + 1 >= ctx->output_cap)) {
+ ctx->overflow = 1;
+ return -1;
+ }
+ ctx->output[ctx->output_len++] = c;
+ return 0;
+}
+
+int fsm_append_len(sashidown_ctx_t *restrict ctx, const char *restrict s, size_t n)
+{
+ if (n == 0)
+ return 0;
+ if (unlikely(ctx->overflow))
+ return -1;
+ if (ctx->write_ptr) {
+ if (unlikely(ctx->write_ptr + n > ctx->write_end)) {
+ ctx->overflow = 1;
+ return -1;
+ }
+ memcpy(ctx->write_ptr, s, n);
+ ctx->write_ptr += n;
+ return 0;
+ }
+ if (unlikely(ctx->output_len + n >= ctx->output_cap)) {
+ ctx->overflow = 1;
+ return -1;
+ }
+ memcpy(ctx->output + ctx->output_len, s, n);
+ ctx->output_len += n;
+ return 0;
+}
+
+int fsm_append_str(sashidown_ctx_t *ctx, const char *s)
+{
+ return fsm_append_len(ctx, s, strlen(s));
+}
+
+void fsm_append_fast(sashidown_ctx_t *ctx, char c)
+{
+ if (unlikely(ctx->overflow)) return;
+ if (unlikely(ctx->write_ptr >= ctx->write_end)) {
+ ctx->overflow = 1;
+ return;
+ }
+ *ctx->write_ptr++ = c;
+}
+
+void fsm_append_len_fast(sashidown_ctx_t *restrict ctx, const char *restrict s, size_t n)
+{
+ if (n == 0) return;
+ if (unlikely(ctx->overflow)) return;
+ if (unlikely(ctx->write_ptr + n > ctx->write_end)) {
+ ctx->overflow = 1;
+ return;
+ }
+ memcpy(ctx->write_ptr, s, n);
+ ctx->write_ptr += n;
+}
diff --git a/vendor/sashidown/src/fsm.h b/vendor/sashidown/src/fsm.h
new file mode 100644
index 0000000..188c301
--- /dev/null
+++ b/vendor/sashidown/src/fsm.h
@@ -0,0 +1,31 @@
+#ifndef MD_FSM_H
+#define MD_FSM_H
+
+#include
+#include
+#include "sashidown.h"
+
+#ifndef likely
+#define likely(x) __builtin_expect(!!(x), 1)
+#endif
+#ifndef unlikely
+#define unlikely(x) __builtin_expect(!!(x), 0)
+#endif
+
+extern const uint8_t CH_HTML_ESCAPE[256];
+extern const uint8_t CH_SCAN_STOP[256];
+extern const uint8_t CH_JSON_ESCAPE[256];
+
+int fsm_append(sashidown_ctx_t *ctx, char c);
+int fsm_append_str(sashidown_ctx_t *ctx, const char *s);
+int fsm_append_len(sashidown_ctx_t *restrict ctx, const char *restrict s, size_t n);
+
+void fsm_append_fast(sashidown_ctx_t *ctx, char c);
+void fsm_append_len_fast(sashidown_ctx_t *restrict ctx, const char *restrict s, size_t n);
+
+void fsm_write_begin(sashidown_ctx_t *ctx);
+void fsm_write_end(sashidown_ctx_t *ctx);
+
+#define fsm_append_lit(ctx, s) fsm_append_len((ctx), (s), sizeof(s) - 1)
+
+#endif /* MD_FSM_H */
diff --git a/vendor/sashidown/src/line_index.c b/vendor/sashidown/src/line_index.c
new file mode 100644
index 0000000..58bc573
--- /dev/null
+++ b/vendor/sashidown/src/line_index.c
@@ -0,0 +1,184 @@
+#include "line_index.h"
+#include
+#include
+
+#define LINE_IX_DYNAMIC_INIT 256
+
+static size_t scan_line_end(const char *input, size_t len, size_t pos)
+{
+ const char *hit = memchr(input + pos, '\n', len - pos);
+ return hit ? (size_t)(hit - input) : len;
+}
+
+static size_t scan_skip_line(const char *input, size_t len, size_t pos)
+{
+ pos = scan_line_end(input, len, pos);
+ if (pos < len)
+ pos++;
+ return pos;
+}
+
+static int line_index_ensure(line_index_t *ix, uint32_t need_slots)
+{
+ if (need_slots <= ix->cap)
+ return 0;
+ if (!ix->owned) {
+ ix->valid = 0;
+ return -1;
+ }
+ uint32_t cap = ix->cap ? ix->cap : LINE_IX_DYNAMIC_INIT;
+ while (cap < need_slots)
+ cap *= 2;
+ uint32_t *p = (uint32_t *)realloc(ix->off, (size_t)cap * sizeof(uint32_t));
+ if (!p) {
+ ix->valid = 0;
+ return -1;
+ }
+ ix->off = p;
+ ix->cap = cap;
+ return 0;
+}
+
+int line_index_init_dynamic(line_index_t *ix, uint32_t initial_cap)
+{
+ if (!ix)
+ return -1;
+ if (initial_cap < 2)
+ initial_cap = LINE_IX_DYNAMIC_INIT;
+ uint32_t *off = (uint32_t *)malloc((size_t)initial_cap * sizeof(uint32_t));
+ if (!off)
+ return -1;
+ ix->off = off;
+ ix->cap = initial_cap;
+ ix->n = 1;
+ ix->off[0] = 0;
+ ix->off[1] = 0;
+ ix->cur = 0;
+ ix->valid = 1;
+ ix->owned = 1;
+ return 0;
+}
+
+void line_index_free(line_index_t *ix)
+{
+ if (!ix)
+ return;
+ if (ix->owned) {
+ free(ix->off);
+ ix->off = NULL;
+ ix->owned = 0;
+ ix->cap = 0;
+ }
+}
+
+void line_index_build(const char *input, size_t len, line_index_t *ix)
+{
+ ix->n = 1;
+ ix->off[0] = 0;
+ ix->cur = 0;
+ ix->valid = 1;
+
+ const char *start = input;
+ const char *end = input + len;
+ const char *hit;
+ while ((hit = memchr(start, '\n', (size_t)(end - start))) != NULL) {
+ if (ix->n + 1 >= ix->cap) {
+ if (line_index_ensure(ix, ix->n + 2) != 0)
+ return;
+ }
+ ix->off[ix->n++] = (uint32_t)(hit - input) + 1;
+ start = hit + 1;
+ }
+ if (ix->n >= ix->cap) {
+ if (line_index_ensure(ix, ix->n + 1) != 0)
+ return;
+ }
+ ix->off[ix->n] = (uint32_t)len;
+}
+
+size_t line_index_extend(line_index_t *ix, const char *input,
+ size_t old_len, size_t new_len)
+{
+ if (!ix || !ix->valid || new_len <= old_len)
+ return 0;
+
+ size_t last_nl = 0;
+ const char *start = input + old_len;
+ const char *end = input + new_len;
+ const char *hit;
+ while ((hit = memchr(start, '\n', (size_t)(end - start))) != NULL) {
+ if (ix->n + 1 >= ix->cap) {
+ if (line_index_ensure(ix, ix->n + 2) != 0)
+ return last_nl;
+ }
+ last_nl = (size_t)(hit - input) + 1;
+ ix->off[ix->n++] = (uint32_t)last_nl;
+ start = hit + 1;
+ }
+ if (ix->n >= ix->cap) {
+ if (line_index_ensure(ix, ix->n + 1) != 0)
+ return last_nl;
+ }
+ ix->off[ix->n] = (uint32_t)new_len;
+ return last_nl;
+}
+
+static uint32_t line_no(const line_index_t *ix, size_t pos)
+{
+ uint32_t lo = 0;
+ uint32_t hi = ix->n;
+ while (lo + 1 < hi) {
+ uint32_t mid = (lo + hi) / 2;
+ if (ix->off[mid] <= pos)
+ lo = mid;
+ else
+ hi = mid;
+ }
+ return lo;
+}
+
+void line_index_seek(line_index_t *ix, size_t pos)
+{
+ if (!ix || !ix->valid)
+ return;
+ ix->cur = line_no(ix, pos);
+}
+
+static uint32_t resolve_line(line_index_t *ix, size_t pos)
+{
+ /* Sequential hot path: render walks lines in order. */
+ if (ix->cur < ix->n && ix->off[ix->cur] == (uint32_t)pos)
+ return ix->cur;
+ if (ix->cur + 1 < ix->n && ix->off[ix->cur + 1] == (uint32_t)pos) {
+ ix->cur++;
+ return ix->cur;
+ }
+ ix->cur = line_no(ix, pos);
+ return ix->cur;
+}
+
+size_t line_end_ix(line_index_t *ix, const char *input, size_t len, size_t pos)
+{
+ if (!ix || !ix->valid)
+ return scan_line_end(input, len, pos);
+
+ uint32_t k = resolve_line(ix, pos);
+ size_t next = (size_t)ix->off[k + 1];
+ if (next > 0 && next <= len && input[next - 1] == '\n')
+ return next - 1;
+ return next <= len ? next : len;
+}
+
+size_t skip_line_ix(line_index_t *ix, const char *input, size_t len, size_t pos)
+{
+ if (!ix || !ix->valid)
+ return scan_skip_line(input, len, pos);
+
+ uint32_t k = resolve_line(ix, pos);
+ size_t next = (size_t)ix->off[k + 1];
+ if (next > len)
+ return len;
+ /* Advance cursor to the next line start (may equal n at EOF). */
+ ix->cur = k + 1;
+ return next;
+}
diff --git a/vendor/sashidown/src/line_index.h b/vendor/sashidown/src/line_index.h
new file mode 100644
index 0000000..b9ebad1
--- /dev/null
+++ b/vendor/sashidown/src/line_index.h
@@ -0,0 +1,40 @@
+#ifndef MD_LINE_INDEX_H
+#define MD_LINE_INDEX_H
+
+#include
+#include
+
+#define SASHIDOWN_MAX_LINE_INDEX 16384
+
+typedef struct {
+ uint32_t *off; /* line-start offsets; off[n] is end sentinel */
+ uint32_t n; /* number of line starts (off[0]..off[n-1]) */
+ uint32_t cur; /* sequential cursor: line index for last lookup */
+ uint32_t cap; /* slots in off[] (must be >= n+1 when valid) */
+ int valid; /* 0 if doc exceeded index capacity / OOM */
+ int owned; /* 1 if off was malloc'd (stream path) */
+} line_index_t;
+
+/* Stack-backed index for zero-malloc parse/convert hot path. */
+#define LINE_INDEX_STACK(name) \
+ uint32_t name##_off_[SASHIDOWN_MAX_LINE_INDEX + 1]; \
+ line_index_t name = { \
+ .off = name##_off_, .n = 0, .cur = 0, \
+ .cap = SASHIDOWN_MAX_LINE_INDEX + 1, .valid = 1, .owned = 0 \
+ }
+
+int line_index_init_dynamic(line_index_t *ix, uint32_t initial_cap);
+void line_index_free(line_index_t *ix);
+
+void line_index_build(const char *input, size_t len, line_index_t *ix);
+
+/* Extend for input[old_len..new_len). Returns absolute offset after the last
+ * newline in that range, or 0 if none. */
+size_t line_index_extend(line_index_t *ix, const char *input,
+ size_t old_len, size_t new_len);
+
+void line_index_seek(line_index_t *ix, size_t pos);
+size_t line_end_ix(line_index_t *ix, const char *input, size_t len, size_t pos);
+size_t skip_line_ix(line_index_t *ix, const char *input, size_t len, size_t pos);
+
+#endif /* MD_LINE_INDEX_H */
diff --git a/vendor/sashidown/src/render.c b/vendor/sashidown/src/render.c
new file mode 100644
index 0000000..66ffe3b
--- /dev/null
+++ b/vendor/sashidown/src/render.c
@@ -0,0 +1,1202 @@
+#include "fsm.h"
+#include "table_stats.h"
+#include "line_index.h"
+#include "block_scan.h"
+#include "render.h"
+#include
+#ifdef __SSE2__
+#include
+#elif defined(__ARM_NEON) || defined(__aarch64__)
+#include
+#endif
+
+static void html_escape(sashidown_ctx_t *ctx, const char *s, size_t n)
+{
+ size_t i = 0;
+#ifdef __SSE2__
+ const __m128i m_amp = _mm_set1_epi8('&');
+ const __m128i m_lt = _mm_set1_epi8('<');
+ const __m128i m_gt = _mm_set1_epi8('>');
+ const __m128i m_quot = _mm_set1_epi8('"');
+ while (i + 16 <= n) {
+ __m128i v = _mm_loadu_si128((const __m128i *)(s + i));
+ __m128i hit = _mm_cmpeq_epi8(v, m_amp);
+ hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_lt));
+ hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_gt));
+ hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_quot));
+ int mask = _mm_movemask_epi8(hit);
+ if (mask) {
+ size_t pos = (unsigned)__builtin_ctz(mask);
+ if (pos > 0)
+ fsm_append_len(ctx, s + i, pos);
+ i += pos;
+ break;
+ }
+ fsm_append_len(ctx, s + i, 16);
+ i += 16;
+ }
+#elif defined(__ARM_NEON) || defined(__aarch64__)
+ const uint8x16_t m_amp = vdupq_n_u8('&');
+ const uint8x16_t m_lt = vdupq_n_u8('<');
+ const uint8x16_t m_gt = vdupq_n_u8('>');
+ const uint8x16_t m_quot = vdupq_n_u8('"');
+ while (i + 16 <= n) {
+ uint8x16_t v = vld1q_u8((const uint8_t *)(s + i));
+ uint8x16_t hit = vceqq_u8(v, m_amp);
+ hit = vorrq_u8(hit, vceqq_u8(v, m_lt));
+ hit = vorrq_u8(hit, vceqq_u8(v, m_gt));
+ hit = vorrq_u8(hit, vceqq_u8(v, m_quot));
+ if (vmaxvq_u8(hit)) {
+ uint8_t lanes[16];
+ vst1q_u8(lanes, hit);
+ size_t pos = 0;
+ while (pos < 16 && !lanes[pos]) pos++;
+ if (pos > 0)
+ fsm_append_len(ctx, s + i, pos);
+ i += pos;
+ break;
+ }
+ fsm_append_len(ctx, s + i, 16);
+ i += 16;
+ }
+#endif
+ while (i < n) {
+ size_t j = i;
+ while (j < n) {
+ unsigned char c = (unsigned char)s[j];
+ if (unlikely(CH_HTML_ESCAPE[c]))
+ break;
+ j++;
+ }
+ if (j > i)
+ fsm_append_len(ctx, s + i, j - i);
+ if (j >= n)
+ break;
+ switch (s[j]) {
+ case '&': fsm_append_lit(ctx, "&"); break;
+ case '<': fsm_append_lit(ctx, "<"); break;
+ case '>': fsm_append_lit(ctx, ">"); break;
+ case '"': fsm_append_lit(ctx, """); break;
+ default: fsm_append(ctx, s[j]); break;
+ }
+ i = j + 1;
+ }
+}
+
+typedef struct {
+ uint8_t bold;
+ uint8_t italic;
+ uint8_t code;
+ uint8_t latex;
+ uint8_t strike;
+} inline_state_t;
+
+static int render_inline_range(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t start, size_t end, int escape_html,
+ inline_state_t *st, int flush);
+
+static void inline_flush_open(sashidown_ctx_t *ctx, inline_state_t *st)
+{
+ if (st->bold) { fsm_append_lit(ctx, ""); st->bold = 0; }
+ if (st->italic) { fsm_append_lit(ctx, ""); st->italic = 0; }
+ if (st->code) { fsm_append_lit(ctx, ""); st->code = 0; }
+ if (st->strike) { fsm_append_lit(ctx, ""); st->strike = 0; }
+}
+
+static void inline_reset(inline_state_t *st)
+{
+ st->bold = st->italic = st->code = st->latex = st->strike = 0;
+}
+
+static void inline_load_from_state(sashidown_render_state_t *rs, inline_state_t *st)
+{
+ st->bold = rs->inline_bold;
+ st->italic = rs->inline_italic;
+ st->code = rs->inline_code;
+ st->latex = rs->inline_latex;
+ st->strike = rs->inline_strike;
+}
+
+static void inline_store_to_state(sashidown_render_state_t *rs, const inline_state_t *st)
+{
+ rs->inline_bold = st->bold;
+ rs->inline_italic = st->italic;
+ rs->inline_code = st->code;
+ rs->inline_latex = st->latex;
+ rs->inline_strike = st->strike;
+}
+
+static void url_attr_escape(sashidown_ctx_t *ctx, const char *s, size_t n)
+{
+ static const char HEX[] = "0123456789ABCDEF";
+ for (size_t i = 0; i < n; i++) {
+ unsigned char c = (unsigned char)s[i];
+ if (c >= 0x80) {
+ char hex[3] = {'%', HEX[c >> 4], HEX[c & 0xF]};
+ fsm_append_len(ctx, hex, 3);
+ } else if (unlikely(CH_HTML_ESCAPE[c])) {
+ switch (c) {
+ case '&': fsm_append_lit(ctx, "&"); break;
+ case '<': fsm_append_lit(ctx, "<"); break;
+ case '>': fsm_append_lit(ctx, ">"); break;
+ case '"': fsm_append_lit(ctx, """); break;
+ default: fsm_append(ctx, (char)c); break;
+ }
+ } else {
+ fsm_append(ctx, (char)c);
+ }
+ }
+}
+
+static int render_autolink(sashidown_ctx_t *ctx, const char *input, size_t end,
+ size_t *pos)
+{
+ size_t i = *pos;
+ if (i >= end || input[i] != '<')
+ return 0;
+ size_t start = i + 1;
+ if (start + 7 > end) /* http:// minimum-ish */
+ return 0;
+ int is_url = (start + 7 <= end && memcmp(input + start, "http://", 7) == 0) ||
+ (start + 8 <= end && memcmp(input + start, "https://", 8) == 0) ||
+ (start + 7 <= end && memcmp(input + start, "mailto:", 7) == 0) ||
+ (start + 6 <= end && memcmp(input + start, "ftp://", 6) == 0);
+ /* also allow bare mailto style: contains @ and no spaces */
+ size_t j = start;
+ while (j < end && input[j] != '>' && input[j] != ' ' &&
+ input[j] != '\n' && input[j] != '\r')
+ j++;
+ if (j >= end || input[j] != '>' || j == start)
+ return 0;
+ if (!is_url) {
+ int has_at = 0;
+ for (size_t k = start; k < j; k++) {
+ if (input[k] == '@') has_at = 1;
+ if (input[k] == '<' ) return 0;
+ }
+ if (!has_at)
+ return 0;
+ }
+ fsm_append_lit(ctx, "");
+ html_escape(ctx, input + start, j - start);
+ fsm_append_lit(ctx, " ");
+ *pos = j + 1;
+ return 1;
+}
+
+static int render_link(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t *pos, int is_image)
+{
+ size_t i = *pos + 1;
+ if (is_image) {
+ if (i >= len || input[i] != '[')
+ return 0;
+ i++;
+ }
+ size_t text_start = i;
+ i = block_scan_until_byte(input, len, i, ']');
+ if (i >= len || input[i] != ']') return 0;
+ size_t text_end = i;
+ i++;
+ if (i >= len || input[i] != '(') return 0;
+ i++;
+ while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
+
+ size_t url_start = i;
+ size_t url_end;
+ if (i < len && input[i] == '<') {
+ url_start = ++i;
+ i = block_scan_until_byte(input, len, i, '>');
+ if (i >= len) return 0;
+ url_end = i;
+ i++;
+ } else {
+ while (i < len && input[i] != ')' && input[i] != ' ' &&
+ input[i] != '\t' && input[i] != '\n' && input[i] != '"')
+ i++;
+ url_end = i;
+ }
+ while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
+
+ size_t title_start = 0, title_end = 0;
+ int has_title = 0;
+ if (i < len && input[i] == '"') {
+ title_start = ++i;
+ i = block_scan_until_byte(input, len, i, '"');
+ if (i >= len) return 0;
+ title_end = i;
+ has_title = 1;
+ i++;
+ while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
+ }
+ if (i >= len || input[i] != ')') return 0;
+
+ if (is_image) {
+ fsm_append_lit(ctx, " ");
+ } else {
+ fsm_append_lit(ctx, "");
+ {
+ inline_state_t link_st = {0, 0, 0, 0, 0};
+ render_inline_range(ctx, input, len, text_start, text_end, 1, &link_st, 1);
+ }
+ fsm_append_lit(ctx, " ");
+ }
+ *pos = i + 1;
+ return 1;
+}
+
+static int render_inline_range(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t start, size_t end, int escape_html,
+ inline_state_t *st, int flush)
+{
+ size_t i = start;
+
+ while (i < end) {
+ if (st->latex) {
+ size_t j = block_scan_until_byte(input, end, i, '$');
+ if (j > i)
+ fsm_append_len(ctx, input + i, j - i);
+ if (j < end) {
+ fsm_append(ctx, '$');
+ st->latex = 0;
+ i = j + 1;
+ } else {
+ i = j;
+ }
+ continue;
+ }
+
+ if (st->code) {
+ size_t j = block_scan_until_byte(input, end, i, '`');
+ if (j > i)
+ fsm_append_len(ctx, input + i, j - i);
+ if (j < end) {
+ fsm_append_lit(ctx, "");
+ st->code = 0;
+ i = j + 1;
+ } else {
+ i = j;
+ }
+ continue;
+ }
+
+ unsigned char ch = (unsigned char)input[i];
+
+ if (likely((ch & 0x80) == 0)) {
+ size_t j = block_scan_plain_ascii(input, end, i);
+ if (j > i) {
+ fsm_append_len(ctx, input + i, j - i);
+ i = j;
+ if (i >= end)
+ break;
+ ch = (unsigned char)input[i];
+ }
+ } else {
+ size_t j = block_scan_utf8_run(input, end, i);
+ if (j > i) {
+ fsm_append_len(ctx, input + i, j - i);
+ i = j;
+ continue;
+ }
+ }
+
+ {
+ static const void *dispatch[256] = {
+ ['$'] = &&lbl_dollar,
+ ['!'] = &&lbl_bang,
+ ['['] = &&lbl_bracket,
+ ['*'] = &&lbl_star,
+ ['_'] = &&lbl_underscore,
+ ['`'] = &&lbl_backtick,
+ ['~'] = &&lbl_tilde,
+ ['\\'] = &&lbl_escape,
+ ['&'] = &&lbl_html,
+ ['<'] = &&lbl_lt,
+ ['>'] = &&lbl_html,
+ };
+ const void *target = dispatch[ch];
+ if (target) goto *target;
+ goto lbl_default;
+
+ lbl_dollar:
+ if (i + 1 < end && input[i + 1] == '$') {
+ size_t j = block_scan_until_2byte(input, end, i + 2, '$', '$');
+ if (j < end) j += 2;
+ fsm_append_len(ctx, input + i, j - i);
+ i = j;
+ continue;
+ }
+ fsm_append(ctx, '$');
+ st->latex = 1;
+ i++;
+ continue;
+
+ lbl_bang:
+ if (i + 1 < end && input[i + 1] == '[') {
+ if (render_link(ctx, input, len, &i, 1))
+ continue;
+ }
+ goto lbl_default;
+
+ lbl_bracket:
+ if (render_link(ctx, input, len, &i, 0))
+ continue;
+ goto lbl_default;
+
+ lbl_star:
+ if (i + 1 < end && input[i + 1] == '*') {
+ if (st->bold) {
+ fsm_append_lit(ctx, "");
+ st->bold = 0;
+ } else {
+ fsm_append_lit(ctx, "");
+ st->bold = 1;
+ }
+ i += 2;
+ continue;
+ }
+ /* Inside **…, a lone * is literal (e.g. CCF A*). */
+ if (st->bold) {
+ fsm_append(ctx, '*');
+ i++;
+ continue;
+ }
+ goto lbl_emph;
+
+ lbl_underscore:
+ if (i + 1 < end && input[i + 1] == '_') {
+ if (st->bold) {
+ fsm_append_lit(ctx, " ");
+ st->bold = 0;
+ } else {
+ fsm_append_lit(ctx, "");
+ st->bold = 1;
+ }
+ i += 2;
+ continue;
+ }
+ goto lbl_emph;
+
+ lbl_emph:
+ if (st->italic) {
+ fsm_append_lit(ctx, "");
+ st->italic = 0;
+ } else {
+ fsm_append_lit(ctx, "");
+ st->italic = 1;
+ }
+ i++;
+ continue;
+
+ lbl_tilde:
+ if (i + 1 < end && input[i + 1] == '~') {
+ if (st->strike) {
+ fsm_append_lit(ctx, "");
+ st->strike = 0;
+ } else {
+ fsm_append_lit(ctx, "");
+ st->strike = 1;
+ }
+ i += 2;
+ continue;
+ }
+ goto lbl_default;
+
+ lbl_escape:
+ if (i + 1 < end) {
+ unsigned char next = (unsigned char)input[i + 1];
+ /* CommonMark: any ASCII punctuation can be escaped */
+ if ((next >= 0x21 && next <= 0x2F) ||
+ (next >= 0x3A && next <= 0x40) ||
+ (next >= 0x5B && next <= 0x60) ||
+ (next >= 0x7B && next <= 0x7E)) {
+ if (escape_html && CH_HTML_ESCAPE[next]) {
+ switch (next) {
+ case '&': fsm_append_lit(ctx, "&"); break;
+ case '<': fsm_append_lit(ctx, "<"); break;
+ case '>': fsm_append_lit(ctx, ">"); break;
+ case '"': fsm_append_lit(ctx, """); break;
+ default: fsm_append(ctx, (char)next); break;
+ }
+ } else {
+ fsm_append(ctx, (char)next);
+ }
+ i += 2;
+ continue;
+ }
+ }
+ fsm_append(ctx, '\\');
+ i++;
+ continue;
+
+ lbl_backtick:
+ fsm_append_lit(ctx, "");
+ st->code = 1;
+ i++;
+ continue;
+
+ lbl_lt:
+ if (render_autolink(ctx, input, end, &i))
+ continue;
+ /* fallthrough to html escape */
+ lbl_html:
+ if (escape_html) {
+ switch (ch) {
+ case '&': fsm_append_lit(ctx, "&"); break;
+ case '<': fsm_append_lit(ctx, "<"); break;
+ case '>': fsm_append_lit(ctx, ">"); break;
+ default: fsm_append(ctx, (char)ch); break;
+ }
+ i++;
+ continue;
+ }
+ /* fallthrough */
+
+ lbl_default:
+ fsm_append(ctx, (char)ch);
+ i++;
+ }
+ }
+
+ if (flush)
+ inline_flush_open(ctx, st);
+
+ return 0;
+}
+
+static void render_table_row_at(sashidown_ctx_t *ctx, const char *input,
+ size_t len, size_t pos, size_t le,
+ int cols, int is_header);
+static void table_close(sashidown_render_state_t *state, sashidown_ctx_t *ctx);
+static int render_table_body_continue(sashidown_ctx_t *ctx,
+ sashidown_render_state_t *state,
+ const char *input, size_t len,
+ size_t *pos_io, size_t stop, int is_final);
+static int try_open_table(sashidown_ctx_t *ctx, sashidown_render_state_t *state,
+ block_env_t *env, const char *input, size_t len,
+ size_t pos, size_t le, size_t stop, int is_final,
+ size_t *pos_out);
+static int render_codeblock(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t *pos, const line_info_t *open_li,
+ size_t stop, int is_final,
+ sashidown_render_state_t *state);
+static int render_latex_block_multiline(sashidown_ctx_t *ctx, const char *input,
+ size_t len, size_t *pos, size_t stop,
+ int is_final, sashidown_render_state_t *state);
+
+static int block_line_is_blank(const char *input, size_t len, size_t pos, size_t le)
+{
+ (void)len;
+ return pos == le || (pos + 1 == le && input[pos] == '\r');
+}
+
+static int block_peek_continues_list(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t pos, size_t limit)
+{
+ while (pos < limit) {
+ size_t le = block_line_end(ctx, input, len, pos);
+ if (block_line_is_blank(input, len, pos, le)) {
+ pos = block_skip_line(ctx, input, len, pos);
+ continue;
+ }
+ line_info_t nli = block_classify_line(ctx, input, len, pos, le);
+ return nli.kind == LK_UL || nli.kind == LK_OL;
+ }
+ return 0;
+}
+
+static void block_close_lists(block_env_t *env, sashidown_ctx_t *ctx)
+{
+ if (env->ul) { fsm_append_lit(ctx, "\n"); env->ul = 0; }
+ if (env->ol) { fsm_append_lit(ctx, "\n"); env->ol = 0; }
+}
+
+static void block_close_para(block_env_t *env, sashidown_ctx_t *ctx,
+ sashidown_render_state_t *rs)
+{
+ if (env->para) {
+ inline_state_t ist;
+ inline_load_from_state(rs, &ist);
+ inline_flush_open(ctx, &ist);
+ fsm_append_lit(ctx, "
\n");
+ env->para = 0;
+ inline_reset(&ist);
+ inline_store_to_state(rs, &ist);
+ }
+}
+
+static void block_close_all(block_env_t *env, sashidown_ctx_t *ctx,
+ sashidown_render_state_t *rs)
+{
+ if (!env->ul && !env->ol && !env->para && !env->bq) return;
+ block_close_lists(env, ctx);
+ block_close_para(env, ctx, rs);
+ if (env->bq) { fsm_append_lit(ctx, "\n"); env->bq = 0; }
+}
+
+static void table_close(sashidown_render_state_t *state, sashidown_ctx_t *ctx)
+{
+ if (!state->in_table)
+ return;
+ fsm_append_lit(ctx, "\n\n");
+ state->in_table = 0;
+ state->table_cols = 0;
+}
+
+void sashidown_render_state_init(sashidown_render_state_t *state)
+{
+ if (state)
+ memset(state, 0, sizeof(*state));
+}
+
+/* Continue an open table: emit body rows until a terminator line (left unconsumed)
+ * or soft-stop. Returns 1 if still in_table waiting for more input. */
+static int render_table_body_continue(sashidown_ctx_t *ctx,
+ sashidown_render_state_t *state,
+ const char *input, size_t len,
+ size_t *pos_io, size_t stop, int is_final)
+{
+ size_t pos = *pos_io;
+ size_t limit = is_final ? len : stop;
+ int cols = state->table_cols;
+
+ while (pos < limit) {
+ size_t le = block_line_end(ctx, input, len, pos);
+ int empty = (pos == le || (pos + 1 == le && input[pos] == '\r'));
+ if (empty) {
+ table_close(state, ctx);
+ *pos_io = pos;
+ return 0;
+ }
+
+ size_t llen = le - pos;
+ if (!memchr(input + pos, '|', llen) ||
+ table_is_separator_row(input + pos, llen)) {
+ table_close(state, ctx);
+ *pos_io = pos;
+ return 0;
+ }
+
+ /* Line must lie fully inside the soft window (newline included). */
+ if (!is_final) {
+ int complete = (le < len && input[le] == '\n' && le + 1 <= stop);
+ if (!complete) {
+ *pos_io = pos;
+ return 1;
+ }
+ }
+
+ render_table_row_at(ctx, input, len, pos, le, cols, 0);
+ pos = block_skip_line(ctx, input, len, pos);
+ if (ctx->overflow) {
+ *pos_io = pos;
+ return 1;
+ }
+ }
+
+ *pos_io = pos;
+ if (is_final) {
+ table_close(state, ctx);
+ return 0;
+ }
+ return state->in_table ? 1 : 0;
+}
+
+/* Try to open a table at pos (line has '|').
+ * Returns 1 if opened (pos_out after sep), 0 if not a table,
+ * -1 if need more input (defer, pos unchanged). */
+static int try_open_table(sashidown_ctx_t *ctx, sashidown_render_state_t *state,
+ block_env_t *env, const char *input, size_t len,
+ size_t pos, size_t le, size_t stop, int is_final,
+ size_t *pos_out)
+{
+ int has_pipe = 0;
+ int cols = table_count_cols(input + pos, le - pos, &has_pipe);
+ if (!has_pipe)
+ return 0;
+
+ if (le >= len || input[le] != '\n')
+ return is_final ? 0 : -1;
+
+ size_t next = le + 1;
+ if (next >= len)
+ return is_final ? 0 : -1;
+ if (!is_final && next >= stop)
+ return -1;
+
+ size_t nle = block_line_end(ctx, input, len, next);
+ if (!table_is_separator_row(input + next, nle - next)) {
+ block_line_seek(ctx, pos);
+ return 0;
+ }
+
+ if (!is_final && nle >= stop && (nle >= len || input[nle] != '\n')) {
+ block_line_seek(ctx, pos);
+ return -1;
+ }
+
+ block_close_all(env, ctx, state);
+ state->pending_valid = 0;
+ state->para_hard_break = 0;
+
+ fsm_append_lit(ctx, "\n\n");
+ render_table_row_at(ctx, input, len, pos, le, cols, 1);
+ fsm_append_lit(ctx, " \n\n");
+ if (ctx->overflow) {
+ /* Partial tags may be in the attempt buffer; stream rewinds state.
+ * Do not commit in_table or advance pos. */
+ block_line_seek(ctx, pos);
+ *pos_out = pos;
+ return 1;
+ }
+
+ state->in_table = 1;
+ state->table_cols = cols;
+ *pos_out = block_skip_line(ctx, input, len, next);
+ return 1;
+}
+
+int sashidown_render_incremental(
+ sashidown_ctx_t *restrict ctx,
+ sashidown_render_state_t *restrict state,
+ const char *restrict input, size_t len,
+ size_t *pos_io,
+ size_t stop,
+ int is_final)
+{
+ size_t pos = *pos_io;
+ block_env_t *env = &state->block;
+
+ if (state->in_fence) {
+ render_codeblock(ctx, input, len, &pos, NULL, stop, is_final, state);
+ *pos_io = pos;
+ if (ctx->overflow)
+ return -1;
+ if (state->in_fence && !is_final && pos >= stop)
+ return 0;
+ }
+
+ if (state->in_latex_block) {
+ render_latex_block_multiline(ctx, input, len, &pos, stop, is_final, state);
+ *pos_io = pos;
+ if (ctx->overflow)
+ return -1;
+ if (state->in_latex_block && !is_final && pos >= stop)
+ return 0;
+ }
+
+ if (state->in_table) {
+ int waiting = render_table_body_continue(ctx, state, input, len,
+ &pos, stop, is_final);
+ *pos_io = pos;
+ if (ctx->overflow)
+ return -1;
+ if (waiting)
+ return 0;
+ /* Terminator line left at pos — fall through to normal dispatch. */
+ }
+
+ size_t limit = is_final ? len : stop;
+
+ while (pos < limit) {
+ size_t le = 0;
+ line_info_t li;
+ int have_li = 0;
+
+ if (state->pending_valid && state->pending_pos == pos) {
+ li = state->pending_li;
+ le = state->pending_le;
+ state->pending_valid = 0;
+ have_li = 1;
+ }
+
+ if (!have_li) {
+ le = block_line_end(ctx, input, len, pos);
+ /* Empty lines: no table/setext probes. */
+ if (block_line_is_blank(input, len, pos, le)) {
+ size_t after = block_skip_line(ctx, input, len, pos);
+ if (!is_final && (env->ul || env->ol) &&
+ block_peek_continues_list(ctx, input, len, after, limit)) {
+ pos = after;
+ continue;
+ }
+ block_close_all(env, ctx, state);
+ state->para_hard_break = 0;
+ pos = after;
+ continue;
+ }
+ }
+
+ /* Table open (scheme C): commit on header+sep, then body streams. */
+ if (memchr(input + pos, '|', le - pos) != NULL) {
+ size_t newt = 0;
+ int tr = try_open_table(ctx, state, env, input, len, pos, le,
+ stop, is_final, &newt);
+ if (tr < 0)
+ break;
+ if (tr > 0) {
+ if (ctx->overflow)
+ goto done;
+ pos = newt;
+ block_line_seek(ctx, pos);
+ if (state->in_table) {
+ int waiting = render_table_body_continue(ctx, state, input, len,
+ &pos, stop, is_final);
+ if (ctx->overflow)
+ goto done;
+ if (waiting)
+ goto done;
+ }
+ continue;
+ }
+ }
+
+ if (!have_li) {
+ li = block_classify_line(ctx, input, len, pos, le);
+ have_li = 1;
+ }
+
+ /* Setext: only paragraph lines; peek next line without dirtying cursor. */
+ if (li.kind == LK_PARA) {
+ line_info_t sli;
+ size_t send = 0;
+ int sr = block_try_setext(ctx, input, len, pos, le, stop, is_final,
+ &sli, &send);
+ if (sr < 0)
+ break;
+ if (sr > 0) {
+ block_close_all(env, ctx, state);
+ state->pending_valid = 0;
+ state->para_hard_break = 0;
+ {
+ int lvl = sli.heading_level;
+ switch (lvl) {
+ case 1: fsm_append_lit(ctx, ""); break;
+ case 2: fsm_append_lit(ctx, ""); break;
+ case 3: fsm_append_lit(ctx, ""); break;
+ case 4: fsm_append_lit(ctx, ""); break;
+ case 5: fsm_append_lit(ctx, ""); break;
+ default: fsm_append_lit(ctx, ""); break;
+ }
+ {
+ inline_state_t ist = {0, 0, 0, 0, 0};
+ render_inline_range(ctx, input, len, sli.content_start,
+ sli.content_end, 1, &ist, 1);
+ }
+ switch (lvl) {
+ case 1: fsm_append_lit(ctx, " \n"); break;
+ case 2: fsm_append_lit(ctx, "\n"); break;
+ case 3: fsm_append_lit(ctx, "\n"); break;
+ case 4: fsm_append_lit(ctx, "\n"); break;
+ case 5: fsm_append_lit(ctx, "\n"); break;
+ default: fsm_append_lit(ctx, "\n"); break;
+ }
+ }
+ pos = send;
+ block_line_seek(ctx, pos);
+ continue;
+ }
+ }
+
+ switch (li.kind) {
+ case LK_EMPTY: {
+ size_t after = block_skip_line(ctx, input, len, pos);
+ if (!is_final && (env->ul || env->ol) &&
+ block_peek_continues_list(ctx, input, len, after, limit)) {
+ pos = after;
+ break;
+ }
+ block_close_all(env, ctx, state);
+ state->para_hard_break = 0;
+ pos = after;
+ break;
+ }
+
+ case LK_HR:
+ block_close_all(env, ctx, state);
+ state->para_hard_break = 0;
+ fsm_append_lit(ctx, " \n");
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_FENCE:
+ block_close_all(env, ctx, state);
+ state->para_hard_break = 0;
+ render_codeblock(ctx, input, len, &pos, &li, stop, is_final, state);
+ if (state->in_fence && !is_final && pos >= stop)
+ goto done;
+ break;
+
+ case LK_LATEX_LINE:
+ block_close_all(env, ctx, state);
+ state->para_hard_break = 0;
+ fsm_append_lit(ctx, "$$");
+ html_escape(ctx, input + li.content_start,
+ li.content_end - li.content_start);
+ fsm_append_lit(ctx, "$$
\n");
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_LATEX_BLOCK:
+ block_close_all(env, ctx, state);
+ state->para_hard_break = 0;
+ render_latex_block_multiline(ctx, input, len, &pos, stop, is_final, state);
+ if (state->in_latex_block && !is_final && pos >= stop)
+ goto done;
+ break;
+
+ case LK_HEADING:
+ block_close_all(env, ctx, state);
+ state->para_hard_break = 0;
+ {
+ int lvl = li.heading_level;
+ size_t hend = (li.content_end > li.content_start) ? li.content_end : le;
+ switch (lvl) {
+ case 1: fsm_append_lit(ctx, ""); break;
+ case 2: fsm_append_lit(ctx, ""); break;
+ case 3: fsm_append_lit(ctx, ""); break;
+ case 4: fsm_append_lit(ctx, ""); break;
+ case 5: fsm_append_lit(ctx, ""); break;
+ default: fsm_append_lit(ctx, ""); break;
+ }
+ {
+ inline_state_t ist = {0, 0, 0, 0, 0};
+ render_inline_range(ctx, input, len, li.content_start, hend, 1, &ist, 1);
+ }
+ switch (lvl) {
+ case 1: fsm_append_lit(ctx, " \n"); break;
+ case 2: fsm_append_lit(ctx, "\n"); break;
+ case 3: fsm_append_lit(ctx, "\n"); break;
+ case 4: fsm_append_lit(ctx, "\n"); break;
+ case 5: fsm_append_lit(ctx, "\n"); break;
+ default: fsm_append_lit(ctx, "\n"); break;
+ }
+ }
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_BQ:
+ block_close_lists(env, ctx);
+ state->para_hard_break = 0;
+ if (!env->bq) { fsm_append_lit(ctx, "\n"); env->bq = 1; }
+ fsm_append_lit(ctx, "");
+ {
+ inline_state_t ist = {0, 0, 0, 0, 0};
+ render_inline_range(ctx, input, len, li.content_start, le, 1, &ist, 1);
+ }
+ fsm_append_lit(ctx, "
\n");
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_UL:
+ if (env->ol) { fsm_append_lit(ctx, "\n"); env->ol = 0; }
+ if (!env->ul) { fsm_append_lit(ctx, "\n"); env->ul = 1; }
+ state->para_hard_break = 0;
+ fsm_append_lit(ctx, "");
+ if (li.task == 1)
+ fsm_append_lit(ctx, " ");
+ else if (li.task == 2)
+ fsm_append_lit(ctx, " ");
+ {
+ inline_state_t ist = {0, 0, 0, 0, 0};
+ render_inline_range(ctx, input, len, li.content_start, le, 1, &ist, 1);
+ }
+ fsm_append_lit(ctx, " \n");
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_OL:
+ if (env->ul) { fsm_append_lit(ctx, " \n"); env->ul = 0; }
+ if (!env->ol) { fsm_append_lit(ctx, "\n"); env->ol = 1; }
+ state->para_hard_break = 0;
+ fsm_append_lit(ctx, "");
+ {
+ inline_state_t ist = {0, 0, 0, 0, 0};
+ render_inline_range(ctx, input, len, li.content_start, le, 1, &ist, 1);
+ }
+ fsm_append_lit(ctx, " \n");
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_PARA:
+ default: {
+ size_t content_end = le;
+ int hard = 0;
+ if (content_end > pos && input[content_end - 1] == '\r')
+ content_end--;
+ if (content_end >= pos + 1 && input[content_end - 1] == '\\') {
+ hard = 1;
+ content_end--;
+ } else if (content_end >= pos + 2 &&
+ input[content_end - 1] == ' ' &&
+ input[content_end - 2] == ' ') {
+ hard = 1;
+ content_end -= 2;
+ }
+
+ if (env->bq) { fsm_append_lit(ctx, " \n"); env->bq = 0; }
+ block_close_lists(env, ctx);
+ if (!env->para) {
+ fsm_append_lit(ctx, "");
+ env->para = 1;
+ } else if (!state->para_hard_break) {
+ fsm_append(ctx, '\n');
+ }
+ {
+ inline_state_t ist;
+ inline_load_from_state(state, &ist);
+ render_inline_range(ctx, input, len, pos, content_end, 1, &ist, 0);
+ inline_store_to_state(state, &ist);
+ }
+ if (hard) {
+ fsm_append_lit(ctx, " \n");
+ state->para_hard_break = 1;
+ } else {
+ state->para_hard_break = 0;
+ }
+ pos = block_skip_line(ctx, input, len, pos);
+ if (pos >= limit) {
+ if (is_final)
+ block_close_para(env, ctx, state);
+ } else {
+ size_t nle = block_line_end(ctx, input, len, pos);
+ line_info_t nli = block_classify_line(ctx, input, len, pos, nle);
+ state->pending_valid = 1;
+ state->pending_pos = pos;
+ state->pending_le = nle;
+ state->pending_li = nli;
+ if (nli.kind != LK_PARA)
+ block_close_para(env, ctx, state);
+ }
+ break;
+ }
+ }
+ }
+
+done:
+ if (is_final) {
+ table_close(state, ctx);
+ block_close_all(env, ctx, state);
+ }
+
+ *pos_io = pos;
+ return ctx->overflow ? -1 : 0;
+}
+
+int sashidown_render(sashidown_ctx_t *ctx, const char *input, size_t len)
+{
+ sashidown_render_state_t state;
+ size_t pos = 0;
+
+ sashidown_render_state_init(&state);
+ fsm_write_begin(ctx);
+ int r = sashidown_render_incremental(ctx, &state, input, len, &pos, len, 1);
+ fsm_write_end(ctx);
+ return r;
+}
+
+static void trim_cell_bounds(const char *input, size_t *start, size_t *end)
+{
+ while (*start < *end && input[*start] == ' ')
+ (*start)++;
+ while (*end > *start && input[*end - 1] == ' ')
+ (*end)--;
+}
+
+static void render_table_cell(sashidown_ctx_t *ctx, const char *input,
+ size_t len, size_t abs_start, size_t abs_end)
+{
+ trim_cell_bounds(input, &abs_start, &abs_end);
+ if (abs_end <= abs_start)
+ return;
+ /* One pass: plain SIMD scan + memcpy / inline FSM (no pre-probe). */
+ {
+ inline_state_t ist = {0, 0, 0, 0, 0};
+ render_inline_range(ctx, input, len, abs_start, abs_end, 1, &ist, 1);
+ }
+}
+
+static void render_table_row_at(sashidown_ctx_t *ctx, const char *input,
+ size_t len, size_t pos, size_t le,
+ int cols, int is_header)
+{
+ fsm_append_lit(ctx, "
");
+ size_t i = pos;
+ while (i < le && (input[i] == ' ' || input[i] == '\t')) i++;
+ if (i < le && input[i] == '|')
+ i++;
+ for (int col = 0; col < cols; col++) {
+ size_t cs = i;
+ while (i < le && input[i] != '|') i++;
+ if (is_header) {
+ fsm_append_lit(ctx, "");
+ render_table_cell(ctx, input, len, cs, i);
+ fsm_append_lit(ctx, " ");
+ } else {
+ fsm_append_lit(ctx, "");
+ render_table_cell(ctx, input, len, cs, i);
+ fsm_append_lit(ctx, " ");
+ }
+ if (i < le && input[i] == '|') i++;
+ }
+ fsm_append_lit(ctx, " \n");
+}
+
+static int render_codeblock(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t *pos, const line_info_t *open_li,
+ size_t stop, int is_final,
+ sashidown_render_state_t *state)
+{
+ if (!state->in_fence) {
+ int fence_len = open_li ? open_li->fence_len : state->fence_len;
+ char fence_char = open_li ? open_li->fence_char : state->fence_char;
+ size_t info_start = open_li ? open_li->info_start : 0;
+ size_t info_end = open_li ? open_li->info_end : 0;
+
+ fsm_append_lit(ctx, " info_start) {
+ size_t s = info_start, e = info_start;
+ while (e < info_end && input[e] != ' ' && input[e] != '\t')
+ e++;
+ if (e > s) {
+ fsm_append_lit(ctx, " class=\"language-");
+ html_escape(ctx, input + s, e - s);
+ fsm_append_lit(ctx, "\"");
+ }
+ }
+ fsm_append(ctx, '>');
+ state->in_fence = 1;
+ state->fence_len = fence_len;
+ state->fence_char = fence_char;
+ state->fence_info_start = info_start;
+ state->fence_info_end = info_end;
+ *pos = block_skip_line(ctx, input, len, *pos);
+ }
+
+ size_t limit = is_final ? len : stop;
+
+ while (*pos < len) {
+ if (*pos >= limit && !is_final) {
+ return 0;
+ }
+
+ int fl = 0;
+ char fc = 0;
+ if (block_is_fence(input, len, *pos, &fl, &fc) &&
+ fc == state->fence_char && fl >= state->fence_len) {
+ /* Closing fence: info string must be empty (CommonMark). */
+ size_t is, ie;
+ block_fence_info_range(input, len, *pos, fl, fc, &is, &ie);
+ if (ie <= is) {
+ *pos = block_skip_line(ctx, input, len, *pos);
+ fsm_append_lit(ctx, " \n");
+ state->in_fence = 0;
+ return 0;
+ }
+ }
+ size_t le = block_line_end(ctx, input, len, *pos);
+ html_escape(ctx, input + *pos, le - *pos);
+ if (le < len) fsm_append(ctx, '\n');
+ *pos = block_skip_line(ctx, input, len, *pos);
+ }
+
+ if (is_final && state->in_fence) {
+ fsm_append_lit(ctx, "\n");
+ state->in_fence = 0;
+ }
+ return 0;
+}
+
+static int render_latex_block_multiline(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t *pos, size_t stop, int is_final,
+ sashidown_render_state_t *state)
+{
+ size_t i;
+ if (!state->in_latex_block) {
+ fsm_append_lit(ctx, "$$");
+ state->in_latex_block = 1;
+ i = *pos + 2;
+ } else {
+ i = *pos;
+ }
+
+ size_t start = i;
+ size_t limit = is_final ? len : stop;
+
+ while (i < len) {
+ if (!is_final && i >= limit) {
+ if (i > start)
+ fsm_append_len(ctx, input + start, i - start);
+ *pos = i;
+ return 0;
+ }
+ size_t scan_end = is_final ? len : limit;
+ if (i >= scan_end) {
+ if (i > start)
+ fsm_append_len(ctx, input + start, i - start);
+ *pos = i;
+ return 0;
+ }
+ const char *hitp = memchr(input + i, '$', scan_end - i);
+ if (!hitp) {
+ if (scan_end > start)
+ fsm_append_len(ctx, input + start, scan_end - start);
+ *pos = scan_end;
+ if (is_final && state->in_latex_block) {
+ fsm_append_lit(ctx, "$$
\n");
+ state->in_latex_block = 0;
+ }
+ return 0;
+ }
+ i = (size_t)(hitp - input);
+ if (i + 1 < len && input[i + 1] == '$') {
+ /* Closing $$ must lie fully before soft-stop when incremental. */
+ if (!is_final && i + 1 >= limit) {
+ if (limit > start)
+ fsm_append_len(ctx, input + start, limit - start);
+ *pos = limit;
+ return 0;
+ }
+ if (i > start)
+ fsm_append_len(ctx, input + start, i - start);
+ fsm_append_lit(ctx, "$$\n");
+ i += 2;
+ if (i < len && input[i] == '\n')
+ i++;
+ *pos = i;
+ state->in_latex_block = 0;
+ return 0;
+ }
+ i++;
+ }
+
+ if (i > start)
+ fsm_append_len(ctx, input + start, i - start);
+ *pos = i;
+
+ if (is_final && state->in_latex_block) {
+ fsm_append_lit(ctx, "$$\n");
+ state->in_latex_block = 0;
+ }
+ return 0;
+}
diff --git a/vendor/sashidown/src/render.h b/vendor/sashidown/src/render.h
new file mode 100644
index 0000000..4d7433e
--- /dev/null
+++ b/vendor/sashidown/src/render.h
@@ -0,0 +1,90 @@
+#ifndef MD_RENDER_H
+#define MD_RENDER_H
+
+#include "sashidown.h"
+#include
+#include
+
+typedef enum {
+ LK_EMPTY,
+ LK_FENCE,
+ LK_HEADING,
+ LK_HR,
+ LK_BQ,
+ LK_UL,
+ LK_OL,
+ LK_LATEX_LINE,
+ LK_LATEX_BLOCK,
+ LK_PARA,
+} line_kind_t;
+
+typedef struct {
+ line_kind_t kind;
+ size_t content_start;
+ size_t content_end;
+ int heading_level;
+ int fence_len;
+ char fence_char; /* '`' or '~' */
+ size_t info_start; /* fence info string */
+ size_t info_end;
+ int task; /* 0=none, 1=unchecked, 2=checked */
+} line_info_t;
+
+/*
+ * [CST State Q] — open-block environment registers (finite CST state).
+ * Tracks which block containers are currently open so the transducer can
+ * emit close tags without an AST. Constant size; independent of |B|.
+ */
+typedef struct {
+ uint8_t ul;
+ uint8_t ol;
+ uint8_t bq;
+ uint8_t para;
+} block_env_t;
+
+/*
+ * [CST State Q] — full confirmation-state transducer state.
+ * Holds block_env plus soft-window / delayed-commit registers (fence,
+ * LaTeX, pending line, table gate). Maps to CST state Q in the paper:
+ * Scan(B[0..stop)) advances Q and emits only confirmed Y.
+ */
+typedef struct {
+ block_env_t block; /* [CST State Q] nested open-block bits */
+ int pending_valid;
+ size_t pending_pos;
+ size_t pending_le;
+ line_info_t pending_li;
+ int in_fence;
+ int fence_len;
+ char fence_char;
+ size_t fence_info_start;
+ size_t fence_info_end;
+ int in_latex_block;
+ size_t latex_start;
+ uint8_t para_hard_break;
+ /* Streaming table (scheme C): after header+sep committed, body rows
+ * emit immediately; close on empty / non-pipe / final. */
+ int in_table;
+ int table_cols;
+ /* Inline emphasis/code spans an open across soft line breaks. */
+ uint8_t inline_bold;
+ uint8_t inline_italic;
+ uint8_t inline_code;
+ uint8_t inline_latex;
+ uint8_t inline_strike;
+} sashidown_render_state_t;
+
+void sashidown_render_state_init(sashidown_render_state_t *state);
+
+/* Render input[start..stop) or full len when is_final. Updates *pos_io. */
+int sashidown_render_incremental(
+ sashidown_ctx_t *restrict ctx,
+ sashidown_render_state_t *restrict state,
+ const char *restrict input, size_t len,
+ size_t *pos_io,
+ size_t stop,
+ int is_final);
+
+int sashidown_render(sashidown_ctx_t *ctx, const char *input, size_t len);
+
+#endif /* MD_RENDER_H */
diff --git a/vendor/sashidown/src/render_ansi.c b/vendor/sashidown/src/render_ansi.c
new file mode 100644
index 0000000..37cf413
--- /dev/null
+++ b/vendor/sashidown/src/render_ansi.c
@@ -0,0 +1,559 @@
+#include "render_ansi.h"
+#include "ansi_style.h"
+#include "block_scan.h"
+#include "table_stats.h"
+#include "fsm.h"
+#include
+#include
+
+typedef struct {
+ uint8_t bold;
+ uint8_t italic;
+ uint8_t code;
+ uint8_t latex;
+ uint8_t strike;
+} ansi_inline_t;
+
+typedef struct {
+ uint8_t ul;
+ uint8_t ol;
+ uint8_t bq;
+ uint8_t para;
+ int ol_num;
+} ansi_env_t;
+
+static void ansi_s(sashidown_ctx_t *ctx, const char *s)
+{
+ if (s && s[0])
+ fsm_append_str(ctx, s);
+}
+
+static void ansi_close_lists(ansi_env_t *env, sashidown_ctx_t *ctx)
+{
+ (void)ctx;
+ env->ul = 0;
+ env->ol = 0;
+ env->ol_num = 0;
+}
+
+static void ansi_close_para(ansi_env_t *env, sashidown_ctx_t *ctx)
+{
+ if (env->para) {
+ fsm_append(ctx, '\n');
+ env->para = 0;
+ }
+}
+
+static void ansi_close_all(ansi_env_t *env, sashidown_ctx_t *ctx)
+{
+ ansi_close_lists(env, ctx);
+ ansi_close_para(env, ctx);
+ env->bq = 0;
+}
+
+static int ansi_render_link(sashidown_ctx_t *ctx, const ansi_theme_t *th,
+ const char *input, size_t len,
+ size_t *pos, int is_image)
+{
+ size_t i = *pos + 1;
+ if (is_image) {
+ if (i >= len || input[i] != '[')
+ return 0;
+ i++;
+ }
+ size_t text_start = i;
+ i = block_scan_until_byte(input, len, i, ']');
+ if (i >= len || input[i] != ']') return 0;
+ size_t text_end = i;
+ i++;
+ if (i >= len || input[i] != '(') return 0;
+ i++;
+ size_t url_start = i;
+ i = block_scan_until_byte(input, len, i, ')');
+ if (i >= len) return 0;
+
+ if (is_image) {
+ ansi_s(ctx, th->image_on);
+ fsm_append_lit(ctx, "[img:");
+ fsm_append_len(ctx, input + text_start, text_end - text_start);
+ fsm_append_lit(ctx, "](");
+ ansi_s(ctx, th->url_on);
+ fsm_append_len(ctx, input + url_start, i - url_start);
+ ansi_s(ctx, th->url_off);
+ fsm_append(ctx, ')');
+ ansi_s(ctx, th->image_off);
+ } else {
+ ansi_s(ctx, th->link_on);
+ fsm_append_len(ctx, input + text_start, text_end - text_start);
+ ansi_s(ctx, th->link_off);
+ fsm_append_lit(ctx, " (");
+ ansi_s(ctx, th->url_on);
+ fsm_append_len(ctx, input + url_start, i - url_start);
+ ansi_s(ctx, th->url_off);
+ fsm_append(ctx, ')');
+ }
+ *pos = i + 1;
+ return 1;
+}
+
+static void ansi_render_inline(sashidown_ctx_t *ctx, const ansi_theme_t *th,
+ const char *input, size_t len,
+ size_t start, size_t end)
+{
+ ansi_inline_t st = {0, 0, 0, 0, 0};
+ size_t i = start;
+
+ while (i < end) {
+ if (st.latex) {
+ size_t j = block_scan_until_byte(input, end, i, '$');
+ if (j > i)
+ fsm_append_len(ctx, input + i, j - i);
+ if (j < end) {
+ fsm_append(ctx, '$');
+ ansi_s(ctx, th->latex_off);
+ st.latex = 0;
+ i = j + 1;
+ } else {
+ i = j;
+ }
+ continue;
+ }
+
+ if (st.code) {
+ size_t j = block_scan_until_byte(input, end, i, '`');
+ if (j > i)
+ fsm_append_len(ctx, input + i, j - i);
+ if (j < end) {
+ ansi_s(ctx, th->code_off);
+ st.code = 0;
+ i = j + 1;
+ } else {
+ i = j;
+ }
+ continue;
+ }
+
+ unsigned char ch = (unsigned char)input[i];
+
+ if ((ch & 0x80) == 0) {
+ size_t j = block_scan_plain_ascii(input, end, i);
+ if (j > i) {
+ fsm_append_len(ctx, input + i, j - i);
+ i = j;
+ if (i >= end)
+ break;
+ ch = (unsigned char)input[i];
+ }
+ } else {
+ size_t j = block_scan_utf8_run(input, end, i);
+ if (j > i) {
+ fsm_append_len(ctx, input + i, j - i);
+ i = j;
+ continue;
+ }
+ }
+
+ if (ch == '$' && i + 1 < end && input[i + 1] == '$') {
+ size_t j = block_scan_until_2byte(input, end, i + 2, '$', '$');
+ if (j < end)
+ j += 2;
+ ansi_s(ctx, th->latex_on);
+ fsm_append_len(ctx, input + i, j - i);
+ ansi_s(ctx, th->latex_off);
+ i = j;
+ continue;
+ }
+
+ if (ch == '$') {
+ ansi_s(ctx, th->latex_on);
+ fsm_append(ctx, '$');
+ st.latex = 1;
+ i++;
+ continue;
+ }
+
+ if (ch == '!' && i + 1 < end && input[i + 1] == '[') {
+ if (ansi_render_link(ctx, th, input, len, &i, 1))
+ continue;
+ }
+
+ if (ch == '[') {
+ if (ansi_render_link(ctx, th, input, len, &i, 0))
+ continue;
+ }
+
+ if (ch == '\\' && i + 1 < end) {
+ unsigned char next = (unsigned char)input[i + 1];
+ if ((next >= 0x21 && next <= 0x2F) ||
+ (next >= 0x3A && next <= 0x40) ||
+ (next >= 0x5B && next <= 0x60) ||
+ (next >= 0x7B && next <= 0x7E)) {
+ fsm_append(ctx, (char)next);
+ i += 2;
+ continue;
+ }
+ }
+
+ if ((ch == '*' || ch == '_') && i + 1 < end && input[i + 1] == (char)ch) {
+ ansi_s(ctx, st.bold ? th->strong_off : th->strong_on);
+ st.bold = !st.bold;
+ i += 2;
+ continue;
+ }
+
+ if (ch == '~' && i + 1 < end && input[i + 1] == '~') {
+ ansi_s(ctx, st.strike ? th->strike_off : th->strike_on);
+ st.strike = !st.strike;
+ i += 2;
+ continue;
+ }
+
+ if (ch == '*' || ch == '_') {
+ ansi_s(ctx, st.italic ? th->emph_off : th->emph_on);
+ st.italic = !st.italic;
+ i++;
+ continue;
+ }
+
+ if (ch == '`') {
+ ansi_s(ctx, th->code_on);
+ st.code = 1;
+ i++;
+ continue;
+ }
+
+ fsm_append(ctx, (char)ch);
+ i++;
+ }
+
+ if (st.bold) ansi_s(ctx, th->strong_off);
+ if (st.italic) ansi_s(ctx, th->emph_off);
+ if (st.code) ansi_s(ctx, th->code_off);
+ if (st.strike) ansi_s(ctx, th->strike_off);
+ if (st.latex) ansi_s(ctx, th->latex_off);
+}
+
+static void trim_cell(const char *input, size_t *start, size_t *end)
+{
+ while (*start < *end && input[*start] == ' ')
+ (*start)++;
+ while (*end > *start && input[*end - 1] == ' ')
+ (*end)--;
+}
+
+static void ansi_table_row_at(sashidown_ctx_t *ctx, const ansi_theme_t *th,
+ const char *input, size_t len,
+ size_t pos, size_t le, int cols, int is_header)
+{
+ size_t i = pos;
+ while (i < le && (input[i] == ' ' || input[i] == '\t')) i++;
+ if (i < le && input[i] == '|')
+ i++;
+
+ for (int col = 0; col < cols; col++) {
+ size_t cs = i;
+ while (i < le && input[i] != '|') i++;
+ size_t ce = i;
+ trim_cell(input, &cs, &ce);
+ if (col)
+ ansi_s(ctx, th->sep);
+ if (is_header)
+ ansi_s(ctx, th->th_on);
+ ansi_render_inline(ctx, th, input, len, cs, ce);
+ if (is_header)
+ ansi_s(ctx, th->th_off);
+ if (i < le && input[i] == '|') i++;
+ }
+ fsm_append(ctx, '\n');
+}
+
+/* Scheme C (batch): header+sep commit, then body rows one-pass. Returns 1 if
+ * a table was consumed (*pos_io advanced past body). */
+static int ansi_try_consume_table(sashidown_ctx_t *ctx, const ansi_theme_t *th,
+ ansi_env_t *env, const char *input, size_t len,
+ size_t pos, size_t le, size_t *pos_io)
+{
+ int has_pipe = 0;
+ int cols = table_count_cols(input + pos, le - pos, &has_pipe);
+ if (!has_pipe)
+ return 0;
+ if (le >= len || input[le] != '\n')
+ return 0;
+
+ size_t next = le + 1;
+ if (next >= len)
+ return 0;
+ size_t nle = block_line_end(ctx, input, len, next);
+ if (!table_is_separator_row(input + next, nle - next)) {
+ block_line_seek(ctx, pos);
+ return 0;
+ }
+
+ ansi_close_all(env, ctx);
+ ansi_table_row_at(ctx, th, input, len, pos, le, cols, 1);
+ pos = block_skip_line(ctx, input, len, next);
+
+ while (pos < len) {
+ size_t ble = block_line_end(ctx, input, len, pos);
+ int empty = (pos == ble || (pos + 1 == ble && input[pos] == '\r'));
+ if (empty)
+ break;
+ size_t blen = ble - pos;
+ if (!memchr(input + pos, '|', blen) ||
+ table_is_separator_row(input + pos, blen))
+ break;
+ ansi_table_row_at(ctx, th, input, len, pos, ble, cols, 0);
+ pos = block_skip_line(ctx, input, len, pos);
+ }
+ fsm_append(ctx, '\n');
+ *pos_io = pos;
+ return 1;
+}
+
+static void ansi_render_codeblock(sashidown_ctx_t *ctx, const ansi_theme_t *th,
+ const char *input, size_t len,
+ size_t *pos, int fence_len, char fence_char)
+{
+ *pos = block_skip_line(ctx, input, len, *pos);
+ ansi_s(ctx, th->fence_on);
+ while (*pos < len) {
+ int fl = 0;
+ char fc = 0;
+ if (block_is_fence(input, len, *pos, &fl, &fc) &&
+ fc == fence_char && fl >= fence_len) {
+ size_t is, ie;
+ block_fence_info_range(input, len, *pos, fl, fc, &is, &ie);
+ if (ie <= is) {
+ *pos = block_skip_line(ctx, input, len, *pos);
+ break;
+ }
+ }
+ size_t le = block_line_end(ctx, input, len, *pos);
+ fsm_append_lit(ctx, " ");
+ fsm_append_len(ctx, input + *pos, le - *pos);
+ fsm_append(ctx, '\n');
+ *pos = block_skip_line(ctx, input, len, *pos);
+ }
+ ansi_s(ctx, th->fence_off);
+ fsm_append(ctx, '\n');
+}
+
+static void ansi_render_latex_block(sashidown_ctx_t *ctx, const ansi_theme_t *th,
+ const char *input, size_t len,
+ size_t *pos)
+{
+ size_t i = *pos + 2;
+ size_t start = i;
+ while (i + 1 < len) {
+ if (input[i] == '$' && input[i + 1] == '$') {
+ ansi_s(ctx, th->latex_on);
+ fsm_append_lit(ctx, "$$");
+ if (i > start)
+ fsm_append_len(ctx, input + start, i - start);
+ fsm_append_lit(ctx, "$$");
+ ansi_s(ctx, th->latex_off);
+ fsm_append(ctx, '\n');
+ i += 2;
+ if (i < len && input[i] == '\n') i++;
+ *pos = i;
+ return;
+ }
+ i++;
+ }
+ ansi_s(ctx, th->latex_on);
+ fsm_append_lit(ctx, "$$");
+ if (i > start)
+ fsm_append_len(ctx, input + start, (i < len ? i : len) - start);
+ fsm_append_lit(ctx, "$$");
+ ansi_s(ctx, th->latex_off);
+ fsm_append(ctx, '\n');
+ *pos = len;
+}
+
+static int sashidown_render_ansi_theme(sashidown_ctx_t *ctx, const char *input, size_t len,
+ const ansi_theme_t *th)
+{
+ ansi_env_t env;
+ memset(&env, 0, sizeof(env));
+ size_t pos = 0;
+
+ fsm_write_begin(ctx);
+
+ while (pos < len) {
+ size_t le = block_line_end(ctx, input, len, pos);
+ if (pos == le || (pos + 1 == le && input[pos] == '\r')) {
+ ansi_close_all(&env, ctx);
+ if (env.bq)
+ env.bq = 0;
+ fsm_append(ctx, '\n');
+ pos = block_skip_line(ctx, input, len, pos);
+ continue;
+ }
+
+ if (memchr(input + pos, '|', le - pos) != NULL) {
+ size_t newt = 0;
+ if (ansi_try_consume_table(ctx, th, &env, input, len, pos, le, &newt)) {
+ pos = newt;
+ block_line_seek(ctx, pos);
+ continue;
+ }
+ }
+
+ line_info_t li = block_classify_line(ctx, input, len, pos, le);
+
+ if (li.kind == LK_PARA) {
+ line_info_t sli;
+ size_t send = 0;
+ int sr = block_try_setext(ctx, input, len, pos, le, len, 1, &sli, &send);
+ if (sr > 0) {
+ int lvl = sli.heading_level;
+ if (lvl < 1) lvl = 1;
+ if (lvl > 6) lvl = 6;
+ ansi_close_all(&env, ctx);
+ ansi_s(ctx, th->heading[lvl]);
+ ansi_render_inline(ctx, th, input, len, sli.content_start, sli.content_end);
+ ansi_s(ctx, th->heading_end);
+ fsm_append(ctx, '\n');
+ if (lvl <= 2)
+ fsm_append(ctx, '\n');
+ pos = send;
+ block_line_seek(ctx, pos);
+ continue;
+ }
+ }
+
+ switch (li.kind) {
+ case LK_EMPTY:
+ ansi_close_all(&env, ctx);
+ if (env.bq)
+ env.bq = 0;
+ fsm_append(ctx, '\n');
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_HR:
+ ansi_close_all(&env, ctx);
+ fsm_append_lit(ctx, "---\n\n");
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_FENCE:
+ ansi_close_all(&env, ctx);
+ ansi_render_codeblock(ctx, th, input, len, &pos, li.fence_len, li.fence_char);
+ break;
+
+ case LK_LATEX_LINE:
+ ansi_close_all(&env, ctx);
+ ansi_s(ctx, th->latex_on);
+ fsm_append_lit(ctx, "$$");
+ fsm_append_len(ctx, input + li.content_start,
+ li.content_end - li.content_start);
+ fsm_append_lit(ctx, "$$");
+ ansi_s(ctx, th->latex_off);
+ fsm_append(ctx, '\n');
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_LATEX_BLOCK:
+ ansi_close_all(&env, ctx);
+ ansi_render_latex_block(ctx, th, input, len, &pos);
+ break;
+
+ case LK_HEADING: {
+ int lvl = li.heading_level;
+ if (lvl < 1) lvl = 1;
+ if (lvl > 6) lvl = 6;
+ ansi_close_all(&env, ctx);
+ ansi_s(ctx, th->heading[lvl]);
+ ansi_render_inline(ctx, th, input, len, li.content_start, le);
+ ansi_s(ctx, th->heading_end);
+ fsm_append(ctx, '\n');
+ if (lvl <= 2)
+ fsm_append(ctx, '\n');
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+ }
+
+ case LK_BQ:
+ ansi_close_lists(&env, ctx);
+ env.bq = 1;
+ ansi_s(ctx, th->bq_prefix);
+ ansi_render_inline(ctx, th, input, len, li.content_start, le);
+ fsm_append(ctx, '\n');
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_UL:
+ if (env.ol) { env.ol = 0; env.ol_num = 0; }
+ env.ul = 1;
+ ansi_s(ctx, th->bullet);
+ if (li.task == 1)
+ fsm_append_lit(ctx, "[ ] ");
+ else if (li.task == 2)
+ fsm_append_lit(ctx, "[x] ");
+ ansi_render_inline(ctx, th, input, len, li.content_start, le);
+ ansi_s(ctx, th->bullet_end);
+ fsm_append(ctx, '\n');
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_OL: {
+ int num = 0;
+ size_t cs = li.content_start;
+ block_is_ol(input, len, pos, &cs, &num);
+ if (env.ul) env.ul = 0;
+ env.ol = 1;
+ char buf[32];
+ int n = snprintf(buf, sizeof(buf), "%d. ", num > 0 ? num : ++env.ol_num);
+ ansi_s(ctx, th->ol_on);
+ if (n > 0)
+ fsm_append_len(ctx, buf, (size_t)n);
+ ansi_s(ctx, th->ol_off);
+ ansi_render_inline(ctx, th, input, len, li.content_start, le);
+ fsm_append(ctx, '\n');
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+ }
+
+ case LK_PARA:
+ default:
+ if (env.bq) env.bq = 0;
+ ansi_close_lists(&env, ctx);
+ if (!env.para) {
+ env.para = 1;
+ } else {
+ fsm_append(ctx, '\n');
+ }
+ ansi_render_inline(ctx, th, input, len, pos, le);
+ pos = block_skip_line(ctx, input, len, pos);
+ if (pos >= len) {
+ ansi_close_para(&env, ctx);
+ } else {
+ size_t nle = block_line_end(ctx, input, len, pos);
+ line_info_t nli = block_classify_line(ctx, input, len, pos, nle);
+ if (nli.kind != LK_PARA)
+ ansi_close_para(&env, ctx);
+ }
+ break;
+ }
+
+ if (ctx->overflow) {
+ fsm_write_end(ctx);
+ return -1;
+ }
+ }
+
+ ansi_close_all(&env, ctx);
+ fsm_write_end(ctx);
+ return ctx->overflow ? -1 : 0;
+}
+
+int sashidown_render_ansi(sashidown_ctx_t *ctx, const char *input, size_t len)
+{
+ return sashidown_render_ansi_theme(ctx, input, len, ansi_theme_color());
+}
+
+int sashidown_render_ansi_plain(sashidown_ctx_t *ctx, const char *input, size_t len)
+{
+ return sashidown_render_ansi_theme(ctx, input, len, ansi_theme_plain());
+}
diff --git a/vendor/sashidown/src/render_ansi.h b/vendor/sashidown/src/render_ansi.h
new file mode 100644
index 0000000..8828757
--- /dev/null
+++ b/vendor/sashidown/src/render_ansi.h
@@ -0,0 +1,11 @@
+#ifndef SASHIDOWN_RENDER_ANSI_H
+#define SASHIDOWN_RENDER_ANSI_H
+
+#include "sashidown.h"
+
+/* Default: built-in colorful theme. */
+int sashidown_render_ansi(sashidown_ctx_t *ctx, const char *input, size_t len);
+/* Monochrome / attribute-only theme. */
+int sashidown_render_ansi_plain(sashidown_ctx_t *ctx, const char *input, size_t len);
+
+#endif
diff --git a/vendor/sashidown/src/render_json.c b/vendor/sashidown/src/render_json.c
new file mode 100644
index 0000000..a7373cd
--- /dev/null
+++ b/vendor/sashidown/src/render_json.c
@@ -0,0 +1,620 @@
+#include "render_json.h"
+#include "block_scan.h"
+#include "table_stats.h"
+#include "fsm.h"
+#include
+#include
+
+typedef struct {
+ uint8_t bold;
+ uint8_t italic;
+ uint8_t code;
+ uint8_t latex;
+ uint8_t strike;
+} json_inline_t;
+
+typedef struct {
+ uint8_t ul;
+ uint8_t ol;
+ uint8_t bq;
+ uint8_t para;
+ int need_comma;
+} json_env_t;
+
+static void json_comma(sashidown_ctx_t *ctx, json_env_t *env)
+{
+ if (env->need_comma)
+ fsm_append(ctx, ',');
+ env->need_comma = 1;
+}
+
+static void json_escape(sashidown_ctx_t *ctx, const char *s, size_t n)
+{
+ size_t i = 0;
+ while (i < n) {
+ size_t j = i;
+ while (j < n) {
+ if (CH_JSON_ESCAPE[(unsigned char)s[j]])
+ break;
+ j++;
+ }
+ if (j > i)
+ fsm_append_len(ctx, s + i, j - i);
+ if (j >= n)
+ break;
+ switch (s[j]) {
+ case '"': fsm_append_lit(ctx, "\\\""); break;
+ case '\\': fsm_append_lit(ctx, "\\\\"); break;
+ case '\n': fsm_append_lit(ctx, "\\n"); break;
+ case '\r': fsm_append_lit(ctx, "\\r"); break;
+ case '\t': fsm_append_lit(ctx, "\\t"); break;
+ default: {
+ char buf[7];
+ int m = snprintf(buf, sizeof(buf), "\\u%04x", (unsigned char)s[j]);
+ if (m > 0)
+ fsm_append_len(ctx, buf, (size_t)m);
+ break;
+ }
+ }
+ i = j + 1;
+ }
+}
+
+static void json_event_simple(sashidown_ctx_t *ctx, json_env_t *env, const char *t)
+{
+ json_comma(ctx, env);
+ fsm_append_lit(ctx, "{\"t\":\"");
+ fsm_append_str(ctx, t);
+ fsm_append_lit(ctx, "\"}");
+}
+
+static void json_event_text(sashidown_ctx_t *ctx, json_env_t *env,
+ const char *s, size_t n)
+{
+ if (n == 0)
+ return;
+ json_comma(ctx, env);
+ fsm_append_lit(ctx, "{\"t\":\"text\",\"v\":\"");
+ json_escape(ctx, s, n);
+ fsm_append_lit(ctx, "\"}");
+}
+
+static void json_event_heading(sashidown_ctx_t *ctx, json_env_t *env, int level)
+{
+ json_comma(ctx, env);
+ char buf[48];
+ int n = snprintf(buf, sizeof(buf), "{\"t\":\"heading\",\"level\":%d}", level);
+ if (n > 0)
+ fsm_append_len(ctx, buf, (size_t)n);
+}
+
+static void json_close_lists(sashidown_ctx_t *ctx, json_env_t *env)
+{
+ if (env->ul) {
+ json_event_simple(ctx, env, "ul_end");
+ env->ul = 0;
+ }
+ if (env->ol) {
+ json_event_simple(ctx, env, "ol_end");
+ env->ol = 0;
+ }
+}
+
+static void json_close_para(sashidown_ctx_t *ctx, json_env_t *env)
+{
+ if (env->para) {
+ json_event_simple(ctx, env, "para_end");
+ env->para = 0;
+ }
+}
+
+static void json_close_bq(sashidown_ctx_t *ctx, json_env_t *env)
+{
+ if (env->bq) {
+ json_event_simple(ctx, env, "bq_end");
+ env->bq = 0;
+ }
+}
+
+static void json_close_all(sashidown_ctx_t *ctx, json_env_t *env)
+{
+ json_close_lists(ctx, env);
+ json_close_para(ctx, env);
+ json_close_bq(ctx, env);
+}
+
+static int json_render_link(sashidown_ctx_t *ctx, json_env_t *env,
+ const char *input, size_t len,
+ size_t *pos, int is_image)
+{
+ size_t i = *pos + 1;
+ if (is_image) {
+ if (i >= len || input[i] != '[')
+ return 0;
+ i++;
+ }
+ size_t text_start = i;
+ i = block_scan_until_byte(input, len, i, ']');
+ if (i >= len || input[i] != ']') return 0;
+ size_t text_end = i;
+ i++;
+ if (i >= len || input[i] != '(') return 0;
+ i++;
+ size_t url_start = i;
+ i = block_scan_until_byte(input, len, i, ')');
+ if (i >= len) return 0;
+
+ json_comma(ctx, env);
+ if (is_image)
+ fsm_append_lit(ctx, "{\"t\":\"image\",\"alt\":\"");
+ else
+ fsm_append_lit(ctx, "{\"t\":\"link\",\"text\":\"");
+ json_escape(ctx, input + text_start, text_end - text_start);
+ fsm_append_lit(ctx, "\",\"href\":\"");
+ json_escape(ctx, input + url_start, i - url_start);
+ fsm_append_lit(ctx, "\"}");
+ *pos = i + 1;
+ return 1;
+}
+
+static void json_flush_text(sashidown_ctx_t *ctx, json_env_t *env,
+ const char *input, size_t *text_start, size_t i)
+{
+ if (i > *text_start)
+ json_event_text(ctx, env, input + *text_start, i - *text_start);
+ *text_start = i;
+}
+
+static void json_render_inline(sashidown_ctx_t *ctx, json_env_t *env,
+ const char *input, size_t len,
+ size_t start, size_t end)
+{
+ json_inline_t st = {0, 0, 0, 0, 0};
+ size_t i = start;
+ size_t text_start = start;
+
+ while (i < end) {
+ if (st.latex) {
+ size_t j = block_scan_until_byte(input, end, i, '$');
+ if (j < end) {
+ json_flush_text(ctx, env, input, &text_start, i);
+ /* include opening $ already emitted into latex stream via text */
+ json_event_text(ctx, env, input + i - 1, (j + 1) - (i - 1));
+ st.latex = 0;
+ i = j + 1;
+ text_start = i;
+ } else {
+ i = j;
+ }
+ continue;
+ }
+
+ if (st.code) {
+ size_t j = block_scan_until_byte(input, end, i, '`');
+ if (j > i)
+ json_event_text(ctx, env, input + i, j - i);
+ if (j < end) {
+ json_event_simple(ctx, env, "code_off");
+ st.code = 0;
+ i = j + 1;
+ text_start = i;
+ } else {
+ i = j;
+ text_start = i;
+ }
+ continue;
+ }
+
+ unsigned char ch = (unsigned char)input[i];
+
+ if ((ch & 0x80) == 0) {
+ size_t j = block_scan_plain_ascii(input, end, i);
+ if (j > i) {
+ i = j;
+ if (i >= end)
+ break;
+ ch = (unsigned char)input[i];
+ }
+ } else {
+ size_t j = block_scan_utf8_run(input, end, i);
+ if (j > i) {
+ i = j;
+ continue;
+ }
+ }
+
+ if (ch == '$' && i + 1 < end && input[i + 1] == '$') {
+ size_t j = block_scan_until_2byte(input, end, i + 2, '$', '$');
+ if (j < end)
+ j += 2;
+ json_flush_text(ctx, env, input, &text_start, i);
+ json_event_text(ctx, env, input + i, j - i);
+ i = j;
+ text_start = i;
+ continue;
+ }
+
+ if (ch == '$') {
+ json_flush_text(ctx, env, input, &text_start, i);
+ st.latex = 1;
+ i++;
+ text_start = i;
+ continue;
+ }
+
+ if (ch == '!' && i + 1 < end && input[i + 1] == '[') {
+ size_t save = i;
+ json_flush_text(ctx, env, input, &text_start, i);
+ if (json_render_link(ctx, env, input, len, &i, 1)) {
+ text_start = i;
+ continue;
+ }
+ i = save;
+ }
+
+ if (ch == '[') {
+ size_t save = i;
+ json_flush_text(ctx, env, input, &text_start, i);
+ if (json_render_link(ctx, env, input, len, &i, 0)) {
+ text_start = i;
+ continue;
+ }
+ i = save;
+ }
+
+ if (ch == '\\' && i + 1 < end) {
+ unsigned char next = (unsigned char)input[i + 1];
+ if ((next >= 0x21 && next <= 0x2F) ||
+ (next >= 0x3A && next <= 0x40) ||
+ (next >= 0x5B && next <= 0x60) ||
+ (next >= 0x7B && next <= 0x7E)) {
+ json_flush_text(ctx, env, input, &text_start, i);
+ json_event_text(ctx, env, input + i + 1, 1);
+ i += 2;
+ text_start = i;
+ continue;
+ }
+ }
+
+ if ((ch == '*' || ch == '_') && i + 1 < end && input[i + 1] == (char)ch) {
+ json_flush_text(ctx, env, input, &text_start, i);
+ json_event_simple(ctx, env, st.bold ? "strong_off" : "strong_on");
+ st.bold = !st.bold;
+ i += 2;
+ text_start = i;
+ continue;
+ }
+
+ if (ch == '~' && i + 1 < end && input[i + 1] == '~') {
+ json_flush_text(ctx, env, input, &text_start, i);
+ json_event_simple(ctx, env, st.strike ? "strike_off" : "strike_on");
+ st.strike = !st.strike;
+ i += 2;
+ text_start = i;
+ continue;
+ }
+
+ if (ch == '*' || ch == '_') {
+ json_flush_text(ctx, env, input, &text_start, i);
+ json_event_simple(ctx, env, st.italic ? "emph_off" : "emph_on");
+ st.italic = !st.italic;
+ i++;
+ text_start = i;
+ continue;
+ }
+
+ if (ch == '`') {
+ json_flush_text(ctx, env, input, &text_start, i);
+ json_event_simple(ctx, env, "code_on");
+ st.code = 1;
+ i++;
+ text_start = i;
+ continue;
+ }
+
+ /* special char that was a scan stop — keep accumulating as text */
+ i++;
+ }
+
+ json_flush_text(ctx, env, input, &text_start, i);
+
+ if (st.bold) json_event_simple(ctx, env, "strong_off");
+ if (st.italic) json_event_simple(ctx, env, "emph_off");
+ if (st.code) json_event_simple(ctx, env, "code_off");
+ if (st.strike) json_event_simple(ctx, env, "strike_off");
+}
+
+static void trim_cell(const char *input, size_t *start, size_t *end)
+{
+ while (*start < *end && input[*start] == ' ')
+ (*start)++;
+ while (*end > *start && input[*end - 1] == ' ')
+ (*end)--;
+}
+
+static void json_table_row_at(sashidown_ctx_t *ctx, json_env_t *env,
+ const char *input, size_t len,
+ size_t pos, size_t le, int cols, int is_header)
+{
+ json_event_simple(ctx, env, is_header ? "tr_header" : "tr");
+ size_t i = pos;
+ while (i < le && (input[i] == ' ' || input[i] == '\t')) i++;
+ if (i < le && input[i] == '|')
+ i++;
+ for (int col = 0; col < cols; col++) {
+ size_t cs = i;
+ while (i < le && input[i] != '|') i++;
+ size_t ce = i;
+ trim_cell(input, &cs, &ce);
+ json_event_simple(ctx, env, is_header ? "th" : "td");
+ json_render_inline(ctx, env, input, len, cs, ce);
+ json_event_simple(ctx, env, is_header ? "th_end" : "td_end");
+ if (i < le && input[i] == '|') i++;
+ }
+ json_event_simple(ctx, env, "tr_end");
+}
+
+/* Scheme C (batch): header+sep commit, body one-pass. */
+static int json_try_consume_table(sashidown_ctx_t *ctx, json_env_t *env,
+ const char *input, size_t len,
+ size_t pos, size_t le, size_t *pos_io)
+{
+ int has_pipe = 0;
+ int cols = table_count_cols(input + pos, le - pos, &has_pipe);
+ if (!has_pipe)
+ return 0;
+ if (le >= len || input[le] != '\n')
+ return 0;
+
+ size_t next = le + 1;
+ if (next >= len)
+ return 0;
+ size_t nle = block_line_end(ctx, input, len, next);
+ if (!table_is_separator_row(input + next, nle - next)) {
+ block_line_seek(ctx, pos);
+ return 0;
+ }
+
+ json_close_all(ctx, env);
+ json_event_simple(ctx, env, "table");
+ json_table_row_at(ctx, env, input, len, pos, le, cols, 1);
+ pos = block_skip_line(ctx, input, len, next);
+
+ while (pos < len) {
+ size_t ble = block_line_end(ctx, input, len, pos);
+ int empty = (pos == ble || (pos + 1 == ble && input[pos] == '\r'));
+ if (empty)
+ break;
+ size_t blen = ble - pos;
+ if (!memchr(input + pos, '|', blen) ||
+ table_is_separator_row(input + pos, blen))
+ break;
+ json_table_row_at(ctx, env, input, len, pos, ble, cols, 0);
+ pos = block_skip_line(ctx, input, len, pos);
+ }
+ json_event_simple(ctx, env, "table_end");
+ *pos_io = pos;
+ return 1;
+}
+
+static void json_render_codeblock(sashidown_ctx_t *ctx, json_env_t *env,
+ const char *input, size_t len,
+ size_t *pos, int fence_len, char fence_char)
+{
+ json_event_simple(ctx, env, "fence");
+ *pos = block_skip_line(ctx, input, len, *pos);
+ while (*pos < len) {
+ int fl = 0;
+ char fc = 0;
+ if (block_is_fence(input, len, *pos, &fl, &fc) &&
+ fc == fence_char && fl >= fence_len) {
+ size_t is, ie;
+ block_fence_info_range(input, len, *pos, fl, fc, &is, &ie);
+ if (ie <= is) {
+ *pos = block_skip_line(ctx, input, len, *pos);
+ break;
+ }
+ }
+ size_t le = block_line_end(ctx, input, len, *pos);
+ json_event_text(ctx, env, input + *pos, le - *pos);
+ if (le < len)
+ json_event_text(ctx, env, "\n", 1);
+ *pos = block_skip_line(ctx, input, len, *pos);
+ }
+ json_event_simple(ctx, env, "fence_end");
+}
+
+static void json_render_latex_block(sashidown_ctx_t *ctx, json_env_t *env,
+ const char *input, size_t len,
+ size_t *pos)
+{
+ size_t i = *pos + 2;
+ size_t start = i;
+ while (i + 1 < len) {
+ if (input[i] == '$' && input[i + 1] == '$') {
+ json_comma(ctx, env);
+ fsm_append_lit(ctx, "{\"t\":\"latex_block\",\"v\":\"");
+ json_escape(ctx, input + start, i - start);
+ fsm_append_lit(ctx, "\"}");
+ i += 2;
+ if (i < len && input[i] == '\n') i++;
+ *pos = i;
+ return;
+ }
+ i++;
+ }
+ json_comma(ctx, env);
+ fsm_append_lit(ctx, "{\"t\":\"latex_block\",\"v\":\"");
+ json_escape(ctx, input + start, (i < len ? i : len) - start);
+ fsm_append_lit(ctx, "\"}");
+ *pos = len;
+}
+
+int sashidown_render_json(sashidown_ctx_t *ctx, const char *input, size_t len)
+{
+ json_env_t env;
+ memset(&env, 0, sizeof(env));
+ size_t pos = 0;
+
+ fsm_write_begin(ctx);
+ fsm_append(ctx, '[');
+
+ while (pos < len) {
+ size_t le = block_line_end(ctx, input, len, pos);
+ if (pos == le || (pos + 1 == le && input[pos] == '\r')) {
+ json_close_all(ctx, &env);
+ pos = block_skip_line(ctx, input, len, pos);
+ continue;
+ }
+
+ if (memchr(input + pos, '|', le - pos) != NULL) {
+ size_t newt = 0;
+ if (json_try_consume_table(ctx, &env, input, len, pos, le, &newt)) {
+ pos = newt;
+ block_line_seek(ctx, pos);
+ continue;
+ }
+ }
+
+ line_info_t li = block_classify_line(ctx, input, len, pos, le);
+
+ if (li.kind == LK_PARA) {
+ line_info_t sli;
+ size_t send = 0;
+ int sr = block_try_setext(ctx, input, len, pos, le, len, 1, &sli, &send);
+ if (sr > 0) {
+ json_close_all(ctx, &env);
+ json_event_heading(ctx, &env, sli.heading_level);
+ json_render_inline(ctx, &env, input, len, sli.content_start, sli.content_end);
+ json_event_simple(ctx, &env, "heading_end");
+ pos = send;
+ block_line_seek(ctx, pos);
+ continue;
+ }
+ }
+
+ switch (li.kind) {
+ case LK_EMPTY:
+ json_close_all(ctx, &env);
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_HR:
+ json_close_all(ctx, &env);
+ json_event_simple(ctx, &env, "hr");
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_FENCE:
+ json_close_all(ctx, &env);
+ json_render_codeblock(ctx, &env, input, len, &pos, li.fence_len, li.fence_char);
+ break;
+
+ case LK_LATEX_LINE:
+ json_close_all(ctx, &env);
+ json_comma(ctx, &env);
+ fsm_append_lit(ctx, "{\"t\":\"latex_block\",\"v\":\"");
+ json_escape(ctx, input + li.content_start,
+ li.content_end - li.content_start);
+ fsm_append_lit(ctx, "\"}");
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_LATEX_BLOCK:
+ json_close_all(ctx, &env);
+ json_render_latex_block(ctx, &env, input, len, &pos);
+ break;
+
+ case LK_HEADING:
+ json_close_all(ctx, &env);
+ json_event_heading(ctx, &env, li.heading_level);
+ json_render_inline(ctx, &env, input, len, li.content_start, le);
+ json_event_simple(ctx, &env, "heading_end");
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_BQ:
+ json_close_lists(ctx, &env);
+ json_close_para(ctx, &env);
+ if (!env.bq) {
+ json_event_simple(ctx, &env, "bq");
+ env.bq = 1;
+ }
+ json_event_simple(ctx, &env, "para");
+ json_render_inline(ctx, &env, input, len, li.content_start, le);
+ json_event_simple(ctx, &env, "para_end");
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_UL:
+ json_close_para(ctx, &env);
+ json_close_bq(ctx, &env);
+ if (env.ol) {
+ json_event_simple(ctx, &env, "ol_end");
+ env.ol = 0;
+ }
+ if (!env.ul) {
+ json_event_simple(ctx, &env, "ul");
+ env.ul = 1;
+ }
+ json_event_simple(ctx, &env, "li");
+ if (li.task == 1)
+ json_event_simple(ctx, &env, "task_unchecked");
+ else if (li.task == 2)
+ json_event_simple(ctx, &env, "task_checked");
+ json_render_inline(ctx, &env, input, len, li.content_start, le);
+ json_event_simple(ctx, &env, "li_end");
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_OL:
+ json_close_para(ctx, &env);
+ json_close_bq(ctx, &env);
+ if (env.ul) {
+ json_event_simple(ctx, &env, "ul_end");
+ env.ul = 0;
+ }
+ if (!env.ol) {
+ json_event_simple(ctx, &env, "ol");
+ env.ol = 1;
+ }
+ json_event_simple(ctx, &env, "li");
+ json_render_inline(ctx, &env, input, len, li.content_start, le);
+ json_event_simple(ctx, &env, "li_end");
+ pos = block_skip_line(ctx, input, len, pos);
+ break;
+
+ case LK_PARA:
+ default:
+ json_close_bq(ctx, &env);
+ json_close_lists(ctx, &env);
+ if (!env.para) {
+ json_event_simple(ctx, &env, "para");
+ env.para = 1;
+ } else {
+ json_event_text(ctx, &env, "\n", 1);
+ }
+ json_render_inline(ctx, &env, input, len, pos, le);
+ pos = block_skip_line(ctx, input, len, pos);
+ if (pos >= len) {
+ json_close_para(ctx, &env);
+ } else {
+ size_t nle = block_line_end(ctx, input, len, pos);
+ line_info_t nli = block_classify_line(ctx, input, len, pos, nle);
+ if (nli.kind != LK_PARA)
+ json_close_para(ctx, &env);
+ }
+ break;
+ }
+
+ if (ctx->overflow) {
+ fsm_write_end(ctx);
+ return -1;
+ }
+ }
+
+ json_close_all(ctx, &env);
+ fsm_append(ctx, ']');
+ fsm_write_end(ctx);
+ return ctx->overflow ? -1 : 0;
+}
diff --git a/vendor/sashidown/src/render_json.h b/vendor/sashidown/src/render_json.h
new file mode 100644
index 0000000..30e4720
--- /dev/null
+++ b/vendor/sashidown/src/render_json.h
@@ -0,0 +1,8 @@
+#ifndef SASHIDOWN_RENDER_JSON_H
+#define SASHIDOWN_RENDER_JSON_H
+
+#include "sashidown.h"
+
+int sashidown_render_json(sashidown_ctx_t *ctx, const char *input, size_t len);
+
+#endif
diff --git a/vendor/sashidown/src/sashidown.c b/vendor/sashidown/src/sashidown.c
new file mode 100644
index 0000000..9e6fc55
--- /dev/null
+++ b/vendor/sashidown/src/sashidown.c
@@ -0,0 +1,96 @@
+#include "sashidown.h"
+#include "line_index.h"
+#include "fsm.h"
+#include "render.h"
+#include "render_ansi.h"
+#include "render_json.h"
+#include
+
+int sashidown_parse(const char *input, size_t len,
+ char *output, size_t cap, size_t *out_len)
+{
+ if (!input || !output || cap == 0)
+ return -1;
+
+ LINE_INDEX_STACK(ix);
+ line_index_build(input, len, &ix);
+
+ sashidown_ctx_t ctx = {
+ .input = input,
+ .input_len = len,
+ .output = output,
+ .output_cap = cap,
+ .output_len = 0,
+ .write_ptr = NULL,
+ .write_end = NULL,
+ .line_ix = &ix,
+ .overflow = 0,
+ };
+
+ if (sashidown_render(&ctx, input, len) != 0) {
+ fsm_write_end(&ctx);
+ return -1;
+ }
+
+ if (ctx.overflow || ctx.output_len + 1 > cap)
+ return -1;
+
+ output[ctx.output_len] = '\0';
+ if (out_len)
+ *out_len = ctx.output_len;
+ return 0;
+}
+
+static int convert_with_render(const char *input, size_t len,
+ char *output, size_t cap, size_t *out_len,
+ int (*render_fn)(sashidown_ctx_t *, const char *, size_t))
+{
+ if (!input || !output || cap == 0)
+ return -1;
+
+ LINE_INDEX_STACK(ix);
+ line_index_build(input, len, &ix);
+
+ sashidown_ctx_t ctx = {
+ .input = input,
+ .input_len = len,
+ .output = output,
+ .output_cap = cap,
+ .output_len = 0,
+ .write_ptr = NULL,
+ .write_end = NULL,
+ .line_ix = &ix,
+ .overflow = 0,
+ };
+
+ if (render_fn(&ctx, input, len) != 0) {
+ fsm_write_end(&ctx);
+ return -1;
+ }
+
+ if (ctx.overflow || ctx.output_len + 1 > cap)
+ return -1;
+
+ output[ctx.output_len] = '\0';
+ if (out_len)
+ *out_len = ctx.output_len;
+ return 0;
+}
+
+int sashidown_convert(const char *input, size_t len,
+ char *output, size_t cap, size_t *out_len,
+ sashidown_format_t format)
+{
+ switch (format) {
+ case SASHIDOWN_FMT_HTML:
+ return sashidown_parse(input, len, output, cap, out_len);
+ case SASHIDOWN_FMT_ANSI:
+ return convert_with_render(input, len, output, cap, out_len, sashidown_render_ansi);
+ case SASHIDOWN_FMT_ANSI_PLAIN:
+ return convert_with_render(input, len, output, cap, out_len, sashidown_render_ansi_plain);
+ case SASHIDOWN_FMT_JSON:
+ return convert_with_render(input, len, output, cap, out_len, sashidown_render_json);
+ default:
+ return -1;
+ }
+}
diff --git a/vendor/sashidown/src/stream.c b/vendor/sashidown/src/stream.c
new file mode 100644
index 0000000..da3d99b
--- /dev/null
+++ b/vendor/sashidown/src/stream.c
@@ -0,0 +1,364 @@
+#include "sashidown.h"
+#include "line_index.h"
+#include "fsm.h"
+#include "render.h"
+#include
+#include
+
+#define STREAM_INPUT_INIT 4096
+#define STREAM_OUTPUT_INIT 8192
+#define STREAM_OUTPUT_MAX (64u * 1024u * 1024u)
+#define STREAM_LINE_IX_INIT 256
+
+struct sashidown_stream {
+ char *input_buf;
+ size_t input_len;
+ size_t input_cap;
+ size_t indexed_len; /* bytes already in line_ix */
+ size_t render_pos;
+ size_t last_complete_nl; /* [0, last_complete_nl) ends on line boundary */
+ sashidown_render_state_t render_state;
+ line_index_t line_ix;
+ char *output_buf;
+ size_t output_cap;
+ sashidown_ctx_t render_ctx;
+ sashidown_stream_cb on_html;
+ void *user;
+ int finished;
+ int aborted;
+};
+
+static int stream_grow_input(sashidown_stream_t *s, size_t need)
+{
+ if (need <= s->input_cap)
+ return 0;
+ size_t cap = s->input_cap ? s->input_cap : STREAM_INPUT_INIT;
+ while (cap < need)
+ cap *= 2;
+ char *p = (char *)realloc(s->input_buf, cap);
+ if (!p)
+ return -1;
+ s->input_buf = p;
+ s->input_cap = cap;
+ return 0;
+}
+
+static int stream_grow_output(sashidown_stream_t *s, size_t need)
+{
+ if (need <= s->output_cap)
+ return 0;
+ size_t cap = s->output_cap ? s->output_cap : STREAM_OUTPUT_INIT;
+ while (cap < need)
+ cap *= 2;
+ if (cap > STREAM_OUTPUT_MAX)
+ cap = STREAM_OUTPUT_MAX;
+ if (cap <= s->output_cap)
+ return -1;
+ char *p = (char *)realloc(s->output_buf, cap);
+ if (!p)
+ return -1;
+ s->output_buf = p;
+ s->output_cap = cap;
+ return 0;
+}
+
+static int stream_flush_html(sashidown_stream_t *s)
+{
+ sashidown_ctx_t *ctx = &s->render_ctx;
+ if (ctx->output_len == 0)
+ return 0;
+ if (!s->on_html)
+ return 0;
+ int r = s->on_html(ctx->output, ctx->output_len, s->user);
+ if (r != 0) {
+ s->aborted = 1;
+ return -1;
+ }
+ ctx->output_len = 0;
+ return 0;
+}
+
+/* Sync line index through input_len; update last_complete_nl. */
+static void stream_catchup_index(sashidown_stream_t *s)
+{
+ if (s->indexed_len >= s->input_len)
+ return;
+
+ if (s->indexed_len == 0) {
+ line_index_build(s->input_buf, s->input_len, &s->line_ix);
+ size_t last = 0;
+ if (s->line_ix.valid && s->line_ix.n > 1) {
+ last = (size_t)s->line_ix.off[s->line_ix.n - 1];
+ } else {
+ const char *p = s->input_buf;
+ const char *end = s->input_buf + s->input_len;
+ const char *hit;
+ while ((hit = memchr(p, '\n', (size_t)(end - p))) != NULL) {
+ last = (size_t)(hit - s->input_buf) + 1;
+ p = hit + 1;
+ }
+ }
+ if (last)
+ s->last_complete_nl = last;
+ } else {
+ size_t nl = line_index_extend(&s->line_ix, s->input_buf,
+ s->indexed_len, s->input_len);
+ if (nl)
+ s->last_complete_nl = nl;
+ }
+ s->indexed_len = s->input_len;
+}
+
+/* 1 = overflow (state restored), 0 = ok, -1 = error */
+static int stream_render_attempt(sashidown_stream_t *s, size_t stop, int is_final)
+{
+ sashidown_ctx_t *ctx = &s->render_ctx;
+ size_t saved_pos = s->render_pos;
+ sashidown_render_state_t saved_state = s->render_state;
+
+ ctx->output_len = 0;
+ ctx->overflow = 0;
+ fsm_write_begin(ctx);
+ int r = sashidown_render_incremental(ctx, &s->render_state,
+ s->input_buf, s->input_len,
+ &s->render_pos, stop, is_final);
+ fsm_write_end(ctx);
+
+ if (ctx->overflow) {
+ s->render_pos = saved_pos;
+ s->render_state = saved_state;
+ return 1;
+ }
+ if (r != 0)
+ return -1;
+ return stream_flush_html(s);
+}
+
+static size_t stream_soft_stop(sashidown_stream_t *s, size_t from, size_t target)
+{
+ if (from >= target)
+ return target;
+
+ /* Keep each attempt roughly within half the output buffer. */
+ size_t soft = from + s->output_cap / 2;
+ if (soft >= target)
+ return target;
+
+ const char *base = s->input_buf + from;
+ size_t lim = soft - from;
+ const char *p = base;
+ const char *end = base + lim;
+ const char *last = NULL;
+ const char *hit;
+ while ((hit = memchr(p, '\n', (size_t)(end - p))) != NULL) {
+ last = hit;
+ p = hit + 1;
+ }
+ if (last)
+ return (size_t)(last - s->input_buf) + 1;
+
+ /* No newline in soft window — take the next one so we still make progress. */
+ hit = memchr(s->input_buf + from, '\n', target - from);
+ if (hit)
+ return (size_t)(hit - s->input_buf) + 1;
+ return target;
+}
+
+static size_t stream_next_line_stop(sashidown_stream_t *s, size_t from, size_t limit)
+{
+ if (from >= limit)
+ return limit;
+ const char *hit = memchr(s->input_buf + from, '\n', limit - from);
+ if (hit)
+ return (size_t)(hit - s->input_buf) + 1;
+ return limit;
+}
+
+/* Render up to pass_stop (is_final as given). Grow/shrink on overflow.
+ * Returns 0 on success, -1 on error. */
+static int stream_render_window(sashidown_stream_t *s, size_t pass_stop, int is_final)
+{
+ sashidown_ctx_t *ctx = &s->render_ctx;
+ for (;;) {
+ int ar = stream_render_attempt(s, pass_stop, is_final);
+ if (ar == 0)
+ return 0;
+ if (ar < 0)
+ return -1;
+ if (stream_grow_output(s, s->output_cap * 2) == 0) {
+ ctx->output = s->output_buf;
+ ctx->output_cap = s->output_cap;
+ continue;
+ }
+ size_t one = stream_next_line_stop(s, s->render_pos, pass_stop);
+ if (one <= s->render_pos || one >= pass_stop)
+ return -1;
+ pass_stop = one;
+ is_final = 0;
+ }
+}
+
+static int stream_render_pass(sashidown_stream_t *s, size_t stop, int is_final)
+{
+ if (s->aborted)
+ return -1;
+ if (!is_final && stop <= s->render_pos)
+ return 0;
+
+ sashidown_ctx_t *ctx = &s->render_ctx;
+ ctx->input = s->input_buf;
+ ctx->input_len = s->input_len;
+
+ size_t target = is_final ? s->input_len : stop;
+
+ /* Common case: one or a few lines already fit in the output buffer. */
+ if (target - s->render_pos <= s->output_cap / 4) {
+ int pass_final = is_final && (target >= s->input_len);
+ size_t before = s->render_pos;
+ if (stream_render_window(s, target, pass_final) != 0)
+ return -1;
+ if (s->render_pos > before || pass_final)
+ return (is_final && !pass_final)
+ ? stream_render_window(s, s->input_len, 1)
+ : 0;
+ if (!is_final)
+ return 0; /* deferred (incomplete table, etc.) */
+ /* fall through to spanned finalization */
+ }
+
+ while (s->render_pos < target) {
+ size_t before = s->render_pos;
+ size_t pass_stop = stream_soft_stop(s, s->render_pos, target);
+ int pass_final = is_final && (pass_stop >= s->input_len);
+
+ if (stream_render_window(s, pass_stop, pass_final) != 0)
+ return -1;
+
+ if (s->render_pos > before) {
+ if (pass_final)
+ return 0;
+ continue;
+ }
+
+ /* No progress: soft window may have deferred a table. Retry full target. */
+ if (pass_stop < target) {
+ pass_final = is_final && (target >= s->input_len);
+ if (stream_render_window(s, target, pass_final) != 0)
+ return -1;
+ if (s->render_pos > before) {
+ if (pass_final)
+ return 0;
+ continue;
+ }
+ }
+
+ /* Still stuck — waiting for more input (incomplete table, etc.). */
+ if (!is_final)
+ return 0;
+ return -1;
+ }
+
+ if (!is_final)
+ return 0;
+
+ /* EOF reached without a finalizing pass — close open blocks. */
+ return stream_render_window(s, s->input_len, 1);
+}
+
+sashidown_stream_t *sashidown_stream_create(const sashidown_stream_opts_t *opts)
+{
+ if (!opts || !opts->on_html)
+ return NULL;
+
+ sashidown_stream_t *s = (sashidown_stream_t *)calloc(1, sizeof(*s));
+ if (!s)
+ return NULL;
+
+ size_t out_cap = opts->output_buf_size ? opts->output_buf_size : STREAM_OUTPUT_INIT;
+ if (out_cap > STREAM_OUTPUT_MAX)
+ out_cap = STREAM_OUTPUT_MAX;
+
+ s->output_cap = out_cap;
+ s->output_buf = (char *)malloc(s->output_cap);
+ if (!s->output_buf) {
+ free(s);
+ return NULL;
+ }
+
+ if (line_index_init_dynamic(&s->line_ix, STREAM_LINE_IX_INIT) != 0) {
+ free(s->output_buf);
+ free(s);
+ return NULL;
+ }
+
+ s->on_html = opts->on_html;
+ s->user = opts->user;
+ sashidown_render_state_init(&s->render_state);
+
+ s->render_ctx.output = s->output_buf;
+ s->render_ctx.output_cap = s->output_cap;
+ s->render_ctx.line_ix = &s->line_ix;
+
+ return s;
+}
+
+int sashidown_stream_feed(sashidown_stream_t *s, const char *chunk, size_t len)
+{
+ if (!s || s->aborted)
+ return -1;
+ if (s->finished)
+ return 0;
+ if (!chunk && len > 0)
+ return -1;
+ if (len == 0)
+ return 0;
+
+ int has_nl = (memchr(chunk, '\n', len) != NULL);
+
+ if (stream_grow_input(s, s->input_len + len) != 0)
+ return -1;
+
+ memcpy(s->input_buf + s->input_len, chunk, len);
+ s->input_len += len;
+
+ /* Tiny-chunk fast path: no newline → defer index + render. */
+ if (!has_nl)
+ return 0;
+
+ stream_catchup_index(s);
+
+ if (s->last_complete_nl <= s->render_pos)
+ return 0;
+
+ return stream_render_pass(s, s->last_complete_nl, 0);
+}
+
+int sashidown_stream_finish(sashidown_stream_t *s)
+{
+ if (!s || s->aborted)
+ return -1;
+ if (s->finished)
+ return 0;
+
+ stream_catchup_index(s);
+ int r = stream_render_pass(s, s->input_len, 1);
+ s->finished = 1;
+ return r;
+}
+
+void sashidown_stream_destroy(sashidown_stream_t *s)
+{
+ if (!s)
+ return;
+ free(s->input_buf);
+ free(s->output_buf);
+ line_index_free(&s->line_ix);
+ free(s);
+}
+
+size_t sashidown_stream_pending(const sashidown_stream_t *s)
+{
+ if (!s || s->input_len <= s->render_pos)
+ return 0;
+ return s->input_len - s->render_pos;
+}
diff --git a/vendor/sashidown/src/table_stats.c b/vendor/sashidown/src/table_stats.c
new file mode 100644
index 0000000..cd4ee7f
--- /dev/null
+++ b/vendor/sashidown/src/table_stats.c
@@ -0,0 +1,137 @@
+#include "table_stats.h"
+#include "line_index.h"
+#include
+
+int table_is_separator_row(const char *line, size_t len)
+{
+ int pipes = 0;
+ int dashes = 0;
+ for (size_t i = 0; i < len; i++) {
+ char c = line[i];
+ if (c == '|') pipes++;
+ else if (c == '-' || c == ':' || c == ' ') dashes++;
+ else if (c != '\r') return 0;
+ }
+ return pipes >= 1 && dashes > 0;
+}
+
+/* GFM: leading/trailing '|' are delimiters, not empty cells. */
+int table_count_cols(const char *line, size_t len, int *has_pipe)
+{
+ if (has_pipe)
+ *has_pipe = 0;
+
+ while (len > 0 && line[len - 1] == '\r')
+ len--;
+
+ size_t start = 0;
+ size_t end = len;
+ while (start < end && (line[start] == ' ' || line[start] == '\t'))
+ start++;
+ if (start < end && line[start] == '|') {
+ start++;
+ if (has_pipe)
+ *has_pipe = 1;
+ }
+ while (end > start && (line[end - 1] == ' ' || line[end - 1] == '\t'))
+ end--;
+ if (end > start && line[end - 1] == '|') {
+ end--;
+ if (has_pipe)
+ *has_pipe = 1;
+ }
+
+ if (start >= end)
+ return 1;
+
+ int cols = 1;
+ for (size_t i = start; i < end; i++) {
+ if (line[i] == '|') {
+ cols++;
+ if (has_pipe)
+ *has_pipe = 1;
+ }
+ }
+ return cols > SASHIDOWN_MAX_COLS ? SASHIDOWN_MAX_COLS : cols;
+}
+
+static size_t te_line_end(sashidown_ctx_t *ctx, const char *input, size_t len, size_t pos)
+{
+ if (ctx && ctx->line_ix)
+ return line_end_ix((line_index_t *)ctx->line_ix, input, len, pos);
+ const char *hit = memchr(input + pos, '\n', len - pos);
+ return hit ? (size_t)(hit - input) : len;
+}
+
+static size_t te_skip_line(sashidown_ctx_t *ctx, const char *input, size_t len, size_t pos)
+{
+ if (ctx && ctx->line_ix)
+ return skip_line_ix((line_index_t *)ctx->line_ix, input, len, pos);
+ pos = te_line_end(NULL, input, len, pos);
+ if (pos < len)
+ pos++;
+ return pos;
+}
+
+static int line_has_pipe_raw(const char *line, size_t len)
+{
+ return memchr(line, '|', len) != NULL;
+}
+
+static int try_parse_table(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t line_start, table_entry_t *e)
+{
+ size_t le = te_line_end(ctx, input, len, line_start);
+ int has_pipe = 0;
+ int cols = table_count_cols(input + line_start, le - line_start, &has_pipe);
+ if (!has_pipe)
+ return 0;
+
+ size_t sep = te_skip_line(ctx, input, len, line_start);
+ if (sep >= len) {
+ if (ctx && ctx->line_ix)
+ line_index_seek((line_index_t *)ctx->line_ix, line_start);
+ return 0;
+ }
+ size_t sep_le = te_line_end(ctx, input, len, sep);
+ if (!table_is_separator_row(input + sep, sep_le - sep)) {
+ if (ctx && ctx->line_ix)
+ line_index_seek((line_index_t *)ctx->line_ix, line_start);
+ return 0;
+ }
+
+ size_t pos = te_skip_line(ctx, input, len, sep);
+
+ e->start = line_start;
+ e->body_start = pos;
+ e->cols = cols; /* GFM: header determines column count */
+ e->body_rows = 0;
+ e->valid = 1;
+
+ while (pos < len) {
+ le = te_line_end(ctx, input, len, pos);
+ size_t line_len = le - pos;
+ if (!line_has_pipe_raw(input + pos, line_len))
+ break;
+ if (table_is_separator_row(input + pos, line_len))
+ break;
+
+ e->body_rows++;
+ pos = te_skip_line(ctx, input, len, pos);
+ }
+
+ e->end = pos;
+ return 1;
+}
+
+int table_try_parse_at(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t pos, table_entry_t *e)
+{
+ e->start = 0;
+ e->end = 0;
+ e->body_start = 0;
+ e->cols = 0;
+ e->body_rows = 0;
+ e->valid = 0;
+ return try_parse_table(ctx, input, len, pos, e);
+}
diff --git a/vendor/sashidown/src/table_stats.h b/vendor/sashidown/src/table_stats.h
new file mode 100644
index 0000000..8566e3b
--- /dev/null
+++ b/vendor/sashidown/src/table_stats.h
@@ -0,0 +1,32 @@
+#ifndef MD_TABLE_STATS_H
+#define MD_TABLE_STATS_H
+
+#include "sashidown.h"
+#include
+#include
+
+#define SASHIDOWN_MAX_COLS 32
+
+/*
+ * [CST Pending Window Metadata] — lightweight table descriptor for the
+ * delayed-commitment pending window. Records header/separator/body bounds
+ * without caching cells; render rescans once after the separator enters
+ * the confirmation frontier. No per-cell heap; O(1) registers only.
+ */
+typedef struct {
+ size_t start; /* header line start */
+ size_t end; /* first byte after the table */
+ size_t body_start; /* first body row (after separator) */
+ int cols; /* column count from header (GFM) */
+ int body_rows;
+ int valid;
+} table_entry_t;
+
+int table_is_separator_row(const char *line, size_t len);
+int table_count_cols(const char *line, size_t len, int *has_pipe);
+
+/* ctx may be NULL (falls back to memchr); when non-NULL reuses line_index. */
+int table_try_parse_at(sashidown_ctx_t *ctx, const char *input, size_t len,
+ size_t pos, table_entry_t *e);
+
+#endif /* MD_TABLE_STATS_H */