summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEthan Werner <mail@ethanwerner.com>2026-10-10 20:55:33 +0000
committerEthan Werner <mail@ethanwerner.com>2026-10-10 20:56:50 +0000
commitf8423ebea413605b42de23f5dd06c5479d3fa1dd (patch)
tree2612a5eb026c59e7aae01f10a1cf62f2ee43042c
initial commitHEADmaster
-rw-r--r--.gitignore24
-rw-r--r--LICENSE21
-rw-r--r--Makefile250
-rw-r--r--README.md142
-rw-r--r--RELEASE_NOTES.md34
-rw-r--r--THIRD_PARTY_NOTICES.md26
-rw-r--r--include/clio.h1493
-rw-r--r--include/dotagents.h1282
-rwxr-xr-xinclude/escape.h272
-rw-r--r--include/frontmatter.h1058
-rw-r--r--include/type.h24
-rw-r--r--src/agent.cpp435
-rw-r--r--src/agent.h41
-rw-r--r--src/compact.cpp596
-rw-r--r--src/compact.h124
-rw-r--r--src/config.cpp732
-rw-r--r--src/config.h106
-rw-r--r--src/escape.c5
-rw-r--r--src/goal.cpp241
-rw-r--r--src/goal.h53
-rw-r--r--src/input.cpp1949
-rw-r--r--src/input.h253
-rw-r--r--src/limits.cpp275
-rw-r--r--src/limits.h40
-rw-r--r--src/llm.h1722
-rw-r--r--src/main.cpp1902
-rw-r--r--src/mcp.cpp1010
-rw-r--r--src/mcp.h112
-rw-r--r--src/message.cpp1
-rw-r--r--src/message.h367
-rw-r--r--src/provider.cpp941
-rw-r--r--src/provider.h41
-rw-r--r--src/session.cpp1555
-rw-r--r--src/session.h293
-rw-r--r--src/shell.cpp303
-rw-r--r--src/shell.h49
-rw-r--r--src/tools.cpp1341
-rw-r--r--src/tools.h118
-rw-r--r--src/util.cpp628
-rw-r--r--src/util.h110
-rw-r--r--src/verbose.cpp55
-rw-r--r--src/verbose.h35
-rw-r--r--tests/test_agent.cpp260
-rw-r--r--tests/test_clio.c884
-rw-r--r--tests/test_compact.cpp517
-rw-r--r--tests/test_config.cpp654
-rw-r--r--tests/test_dotagents.c618
-rw-r--r--tests/test_frontmatter.c236
-rw-r--r--tests/test_goal.cpp230
-rw-r--r--tests/test_input.cpp1263
-rw-r--r--tests/test_limits.cpp379
-rw-r--r--tests/test_mcp.cpp579
-rw-r--r--tests/test_provider.cpp647
-rw-r--r--tests/test_quine.c27
-rw-r--r--tests/test_session.cpp1659
-rw-r--r--tests/test_shell.cpp131
-rw-r--r--tests/test_tools.cpp1174
-rw-r--r--valgrind.supp115
-rw-r--r--vendor/src/cJSON-1.7.19.tar.gzbin0 -> 356247 bytes
-rw-r--r--vendor/src/curl-8.10.1.tar.gzbin0 -> 4129962 bytes
-rw-r--r--vendor/src/musl-1.2.5.tar.gzbin0 -> 1080786 bytes
-rw-r--r--vendor/src/nlohmann-json-3.12.0.tar.gzbin0 -> 145067 bytes
-rw-r--r--vendor/src/openssl-3.3.2.tar.gzbin0 -> 18076531 bytes
-rw-r--r--vendor/src/zlib-1.3.1.tar.gzbin0 -> 1512791 bytes
64 files changed, 29432 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..56c9003
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,24 @@
+# Build outputs
+/llsh
+/src/*.o
+/tests/test_*
+!/tests/test_*.c
+!/tests/test_*.cpp
+/tests/*.o
+/build-asan/
+/vg.log
+
+# Generated vendor build trees (sources are kept in vendor/src/*.tar.gz)
+/vendor/build/
+/vendor/src/_extract/
+/vendor/bin/
+/vendor/include/
+/vendor/lib/
+/vendor/libstdc++/
+/vendor/musl-1.2.5/
+/vendor/musl/
+
+# Local editor and debug artifacts
+/.debug.json.swp
+/.ruff_cache/
+/debug.json
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..77dedc0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 llsh contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..04edb8c
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,250 @@
+# llsh — host-toolchain build with locally built static library archives.
+#
+# Supported target: x86_64 Linux. The bundled source archives are compiled
+# with the host GCC toolchain and linked against the system glibc/static C++
+# runtime. No musl toolchain or musl shim is used.
+
+SHELL ?= /bin/sh
+
+ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
+VENDOR := $(ROOT)/vendor
+VEXT := $(VENDOR)/src/_extract
+BUILD := $(VENDOR)/build
+ZLIB_D := $(BUILD)/zlib
+OPENSSL_D := $(BUILD)/openssl
+CURL_D := $(BUILD)/curl
+CJSON_D := $(BUILD)/cjson
+NLOHMANN_D := $(BUILD)/nlohmann
+SYSRT := $(BUILD)/system-runtime
+SYSRT_LINKS := $(SYSRT)/linker-scripts
+GCC_TRIPLE := $(shell $(CXX) -dumpmachine)
+GCC_MAJOR := $(shell $(CXX) -dumpversion | cut -d. -f1)
+SYS_GCC_LIBDIR := $(SYSRT)/usr/lib/gcc/$(GCC_TRIPLE)/$(GCC_MAJOR)
+
+CC ?= gcc
+CXX ?= g++
+AR ?= ar
+RANLIB ?= ranlib
+STATIC ?= 1
+
+CFLAGS ?= -D_GNU_SOURCE -std=gnu11 -Os -g -Wall -Wextra \
+ -Wno-unused-parameter -ffunction-sections -fdata-sections
+CXXFLAGS ?= -D_GNU_SOURCE -std=gnu++20 -Os -g -Wall -Wextra \
+ -Wno-unused-parameter -Wno-write-strings \
+ -Wno-missing-field-initializers \
+ -ffunction-sections -fdata-sections
+
+CPPFLAGS += -I. -Isrc -Iinclude \
+ -I$(ZLIB_D)/include \
+ -I$(OPENSSL_D)/include \
+ -I$(CURL_D)/include \
+ -I$(CJSON_D)/include \
+ -I$(NLOHMANN_D)/include
+
+VLIBS := \
+ $(CURL_D)/lib/libcurl.a \
+ $(OPENSSL_D)/lib64/libssl.a \
+ $(OPENSSL_D)/lib64/libcrypto.a \
+ $(ZLIB_D)/lib/libz.a \
+ $(CJSON_D)/lib/libcjson.a
+
+LIBS := -lm -ldl -pthread
+ifeq ($(STATIC),1)
+LINK_MODE := -static
+RUNTIME_TARGET := static-runtime
+STATIC_LIB_DIRS := -L$(SYSRT_LINKS) -L$(SYSRT)/usr/lib64 \
+ -L$(SYS_GCC_LIBDIR)
+else
+LINK_MODE :=
+RUNTIME_TARGET :=
+STATIC_LIB_DIRS :=
+endif
+
+BIN := llsh
+MAIN := src/main.cpp
+MAIN_OBJ := src/main.o
+LIB_SRC_CPP := $(wildcard src/*.cpp)
+LIB_SRC_C := src/escape.c
+LIB_SRC := $(LIB_SRC_CPP) $(LIB_SRC_C)
+LIB_OBJ := $(LIB_SRC_CPP:.cpp=.o) $(LIB_SRC_C:.c=.o)
+LIB_OBJ_NO_MAIN := $(filter-out $(MAIN_OBJ),$(LIB_OBJ))
+
+TEST_SRC_CPP := $(wildcard tests/test_*.cpp)
+TEST_SRC_C := tests/test_frontmatter.c tests/test_dotagents.c \
+ tests/test_quine.c tests/test_clio.c
+TEST_SRC := $(TEST_SRC_CPP) $(TEST_SRC_C)
+TEST_BIN := $(TEST_SRC_CPP:.cpp=) $(TEST_SRC_C:.c=)
+LINKED_TESTS := $(TEST_SRC_CPP:.cpp=)
+
+HEADERS := $(wildcard src/*.h) $(wildcard include/*.h)
+
+ASAN_CC ?= clang
+ASAN_CXX ?= clang++
+ASAN_FLAGS := -fsanitize=address,undefined -fno-omit-frame-pointer
+ASANDIR := build-asan
+LIB_SRC_NO_MAIN := $(filter-out $(MAIN),$(LIB_SRC))
+
+.PHONY: all test valgrind asan clean vendor vendor-deps vendor-clean static-runtime
+
+all: $(BIN) $(TEST_BIN)
+
+# --------------------------------------------------------------- dependencies
+# Builds the bundled upstream sources with the host compiler. These are
+# ordinary glibc-targeting static archives; the final link uses host glibc.
+vendor-deps:
+ @set -e; \
+ mkdir -p "$(VEXT)"; \
+ for t in $(VENDOR)/src/*.tar.gz; do \
+ b=$$(basename $$t .tar.gz); \
+ [ -d "$(VEXT)/$$b" ] || tar xzf "$$t" -C "$(VEXT)"; \
+ done; \
+ if [ ! -f "$(NLOHMANN_D)/include/nlohmann/json.hpp" ]; then \
+ mkdir -p "$(NLOHMANN_D)"; \
+ cp -r "$(VEXT)/nlohmann-json-3.12.0/include" "$(NLOHMANN_D)/"; \
+ fi; \
+ if [ ! -f "$(ZLIB_D)/lib/libz.a" ]; then \
+ cd "$(VEXT)/zlib-1.3.1"; \
+ make distclean >/dev/null 2>&1 || true; \
+ CC="$(CC)" CFLAGS="-Os -ffunction-sections -fdata-sections" \
+ ./configure --prefix="$(ZLIB_D)" --static >/dev/null; \
+ make -j4 >/dev/null; make install >/dev/null; \
+ fi; \
+ if [ ! -f "$(OPENSSL_D)/lib64/libcrypto.a" ]; then \
+ cd "$(VEXT)/openssl-3.3.2"; \
+ CC="$(CC)" CFLAGS="-Os -fPIC" AR="$(AR)" RANLIB="$(RANLIB)" \
+ ./Configure linux-x86_64 no-shared no-tests no-dso \
+ --prefix="$(OPENSSL_D)" >/dev/null; \
+ make -j4 >/dev/null; make install_sw >/dev/null; \
+ fi; \
+ if [ ! -f "$(CJSON_D)/lib/libcjson.a" ]; then \
+ mkdir -p "$(CJSON_D)/include/cjson" "$(CJSON_D)/lib"; \
+ "$(CC)" -Os -ffunction-sections -fdata-sections -fPIC \
+ -c "$(VEXT)/cJSON-1.7.19/cJSON.c" -o "$(CJSON_D)/lib/cJSON.o"; \
+ "$(AR)" rcs "$(CJSON_D)/lib/libcjson.a" "$(CJSON_D)/lib/cJSON.o"; \
+ "$(RANLIB)" "$(CJSON_D)/lib/libcjson.a"; \
+ cp "$(VEXT)/cJSON-1.7.19/cJSON.h" "$(CJSON_D)/include/cjson/"; \
+ fi; \
+ if [ ! -f "$(CURL_D)/lib/libcurl.a" ]; then \
+ cd "$(VEXT)/curl-8.10.1"; \
+ CC="$(CC)" CFLAGS="-Os -ffunction-sections -fdata-sections" \
+ LDFLAGS="-Wl,--gc-sections" \
+ ./configure --host=x86_64-linux --prefix="$(CURL_D)" \
+ --disable-shared --enable-static \
+ --with-ssl="$(OPENSSL_D)" --with-zlib="$(ZLIB_D)" \
+ --disable-ftp --disable-rtsp --disable-dict --disable-telnet \
+ --disable-imap --disable-pop3 --disable-smtp --disable-ldap \
+ --without-proxy --without-metalink --without-libpsl \
+ --without-fish --without-gssapi --without-brotli --without-zstd \
+ --without-libssh2 --without-nghttp2 --without-ngtcp2 \
+ --without-libidn2 --without-libgsasl >/dev/null; \
+ make -j4 >/dev/null; make install >/dev/null; \
+ fi
+
+# Fedora does not install the static system C/C++ runtimes by default. Fetch
+# the matching distro packages into the ignored build tree (no system install
+# or sudo required) and link against those package archives.
+static-runtime:
+ @set -e; \
+ mkdir -p "$(SYSRT)"; \
+ if [ ! -f "$(SYSRT)/usr/lib64/libc.a" ] || \
+ [ ! -f "$(SYS_GCC_LIBDIR)/libstdc++.a" ]; then \
+ glibc_ver=$$(rpm -q --qf '%{VERSION}-%{RELEASE}.%{ARCH}' glibc); \
+ gcc_ver=$$(rpm -q --qf '%{VERSION}-%{RELEASE}.%{ARCH}' libstdc++); \
+ dnf download --destdir="$(SYSRT)" \
+ "glibc-static-$$glibc_ver" "libstdc++-static-$$gcc_ver" >/dev/null; \
+ for r in "$(SYSRT)"/*.rpm; do \
+ rpm2cpio "$$r" | (cd "$(SYSRT)" && cpio -idm --quiet 2>/dev/null); \
+ done; \
+ fi; \
+ mkdir -p "$(SYSRT_LINKS)"; \
+ sed "s#/usr/lib64/#$(SYSRT)/usr/lib64/#g" \
+ "$(SYSRT)/usr/lib64/libm.a" > "$(SYSRT_LINKS)/libm.a"; \
+ test -f "$(SYSRT)/usr/lib64/libc.a"; \
+ test -f "$(SYS_GCC_LIBDIR)/libstdc++.a"
+
+vendor: vendor-deps
+
+# ------------------------------------------------------------------- app
+$(BIN): $(MAIN_OBJ) $(LIB_OBJ_NO_MAIN) | vendor-deps $(RUNTIME_TARGET)
+ $(CXX) $(CXXFLAGS) $(CPPFLAGS) $(LINK_MODE) $(STATIC_LIB_DIRS) -o $@ $^ \
+ $(VLIBS) $(LIBS) -Wl,--gc-sections -s -Wl,--build-id=none
+
+src/%.o: src/%.cpp $(HEADERS) | vendor-deps
+ $(CXX) $(CXXFLAGS) $(CPPFLAGS) -c $< -o $@
+
+src/%.o: src/%.c $(HEADERS) | vendor-deps
+ $(CC) $(CFLAGS) $(CPPFLAGS) -c $< -o $@
+
+tests/%.o: tests/%.cpp $(HEADERS) | vendor-deps
+ $(CXX) $(CXXFLAGS) $(CPPFLAGS) -c $< -o $@
+
+tests/%.o: tests/%.c $(HEADERS) | vendor-deps
+ $(CC) $(CFLAGS) $(CPPFLAGS) -c $< -o $@
+
+# Header-only portability tests.
+tests/test_frontmatter: tests/test_frontmatter.o | vendor-deps $(RUNTIME_TARGET)
+ $(CC) $(CFLAGS) -o $@ $< $(STATIC_LIB_DIRS) $(LINK_MODE)
+
+tests/test_dotagents: tests/test_dotagents.o | vendor-deps $(RUNTIME_TARGET)
+ $(CC) $(CFLAGS) -o $@ $< $(STATIC_LIB_DIRS) $(LINK_MODE)
+
+tests/test_clio: tests/test_clio.o | vendor-deps $(RUNTIME_TARGET)
+ $(CC) $(CFLAGS) -o $@ $< $(STATIC_LIB_DIRS) $(LINK_MODE)
+
+tests/test_quine: tests/test_quine.c | vendor-deps $(RUNTIME_TARGET)
+ $(CC) $(CFLAGS) -o $@ $< $(STATIC_LIB_DIRS) $(LINK_MODE)
+
+$(LINKED_TESTS): %: %.o $(LIB_OBJ_NO_MAIN) | vendor-deps $(RUNTIME_TARGET)
+ $(CXX) $(CXXFLAGS) $(CPPFLAGS) $(LINK_MODE) $(STATIC_LIB_DIRS) -o $@ \
+ $< $(LIB_OBJ_NO_MAIN) $(VLIBS) $(LIBS) -Wl,--gc-sections -s
+
+test: all
+ @set -e; for t in $(TEST_BIN); do echo "== $$t"; ./$$t; done; \
+ echo "ALL TESTS PASSED"
+
+valgrind: all
+ @set -e; \
+ for t in $(TEST_BIN); do \
+ echo "== $$t"; \
+ valgrind --quiet --leak-check=full --error-exitcode=9 \
+ --suppressions=valgrind.supp ./$$t >/dev/null; \
+ done; \
+ echo "VALGRIND CLEAN"
+
+# The sanitizer executable uses dynamic host runtimes by design; application
+# and dependency code are instrumented independently in a scratch directory.
+asan: vendor-deps
+ @rm -rf $(ASANDIR); mkdir -p $(ASANDIR)
+ @set -e; \
+ libs=""; \
+ for f in $(LIB_SRC_NO_MAIN); do \
+ b=$$(basename $$f); b=$${b%.c}; b=$${b%.cpp}; \
+ case $$f in \
+ *.cpp) $(ASAN_CXX) $(CXXFLAGS) $(ASAN_FLAGS) $(CPPFLAGS) -c $$f -o $(ASANDIR)/$$b.o ;; \
+ *) $(ASAN_CC) $(CFLAGS) $(ASAN_FLAGS) $(CPPFLAGS) -c $$f -o $(ASANDIR)/$$b.o ;; \
+ esac; \
+ libs="$$libs $(ASANDIR)/$$b.o"; \
+ done; \
+ for f in $(TEST_SRC); do \
+ b=$$(basename $$f); b=$${b%.c}; b=$${b%.cpp}; \
+ case $$f in \
+ *.cpp) $(ASAN_CXX) $(CXXFLAGS) $(ASAN_FLAGS) $(CPPFLAGS) -c $$f -o $(ASANDIR)/$$b.test.o ;; \
+ *) $(ASAN_CC) $(CFLAGS) $(ASAN_FLAGS) $(CPPFLAGS) -c $$f -o $(ASANDIR)/$$b.test.o ;; \
+ esac; \
+ case $$b in \
+ test_clio) $(ASAN_CXX) $(ASAN_FLAGS) -o $(ASANDIR)/$$b.test $(ASANDIR)/$$b.test.o $(LIBS) ;; \
+ *) $(ASAN_CXX) $(ASAN_FLAGS) -o $(ASANDIR)/$$b.test $(ASANDIR)/$$b.test.o $$libs $(VLIBS) $(LIBS) ;; \
+ esac; \
+ echo "== $$b"; \
+ ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 \
+ UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 \
+ $(ASANDIR)/$$b.test >/dev/null; \
+ done; \
+ echo "ASAN CLEAN"
+
+clean:
+ rm -f $(LIB_OBJ) $(TEST_BIN) tests/*.o $(BIN) vg.log
+ rm -rf $(ASANDIR)
+
+vendor-clean:
+ rm -rf $(BUILD) $(VEXT)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..08a24ec
--- /dev/null
+++ b/README.md
@@ -0,0 +1,142 @@
+# llsh
+
+`llsh` is a statically linked terminal LLM agent for Linux. It supports
+OpenAI-compatible APIs, Anthropic, Ollama, shell and file tools, session
+history, skills and memories from `.agents/`, and optional MCP servers.
+
+## Build
+
+The repository includes source archives for curl, OpenSSL, zlib, cJSON, and
+nlohmann/json. The host GCC toolchain builds their static archives. On x86_64
+Fedora, install GNU Make, GCC/G++, Perl, `rpm`, `dnf`, `rpm2cpio`, and `cpio`,
+and allow access to Fedora repositories. The build downloads matching
+`glibc-static` and `libstdc++-static` RPMs into the ignored build directory;
+it does not install packages system-wide. Then run:
+
+```sh
+make
+```
+
+The default build creates a statically linked `./llsh` executable. To build
+and run the test suite:
+
+```sh
+make test
+```
+
+Optional checks are `make asan` (requires Clang) and `make valgrind` (requires
+Valgrind). If static glibc/libstdc++ packages are unavailable, run tests with
+the host shared runtimes:
+
+```sh
+make STATIC=0 test
+```
+
+`make clean` removes application/test outputs; `make vendor-clean` removes
+generated third-party archives so they can be rebuilt from the bundled
+sources.
+
+## Configure a model
+
+The simplest invocation supplies a provider and its credentials:
+
+```sh
+./llsh --provider openai --api-key "$OPENAI_API_KEY" "Explain this project"
+```
+
+The CLI supports `openai`, `anthropic`, and `ollama`. OpenAI-compatible
+endpoints can be selected with `--base-url`; Ollama defaults to
+`http://localhost:11434` and does not require an API key.
+
+For repeat use, configure `.agents/models.json` with a default preset:
+
+```json
+{
+ "default": "work",
+ "presets": {
+ "work": {
+ "provider": "openai",
+ "model": "gpt-5.2",
+ "api_key": "replace-with-a-key"
+ }
+ }
+}
+```
+
+Keep credentials out of committed project files. CLI options override preset
+values. See `./llsh --help` for the complete list of options.
+
+When started without a prompt in a terminal, `llsh` opens an interactive REPL.
+With a prompt argument, it runs one turn and exits:
+
+```sh
+./llsh "Summarize the files in this directory"
+```
+
+The interactive commands include `/help`, `/model`, `/agent`, `/save`,
+`/export`, `/compact`, and `/exit`. The shell can be entered with Backtab.
+
+## Tools and workspace configuration
+
+The built-in tools are `read_file`, `write_file`, `run_shell`, `use_skill`,
+`search_memories`, `embed`, `llm`, and `compact`. File and shell operations
+use the process working directory; `-C, --cwd` selects it.
+
+Model presets in `.agents/models.json` configure model connections and model
+limits; they do not configure shell policy. Put llsh runtime tool settings in
+`.agents/speakmcp-settings.json` instead:
+
+```json
+{
+ "shell_allowlist": ["git", "rg", "cat"],
+ "shell_denylist": ["git push", "rm"],
+ "confirm_destructive": true,
+ "shell_timeout_secs": 45,
+ "max_output_chars": 9000,
+ "max_file_bytes": 700000
+}
+```
+
+The global `~/.agents/speakmcp-settings.json` values are shallow-merged with
+workspace settings; workspace keys override global keys. Explicit CLI shell
+policy and limit flags override settings.
+
+llsh is not a sandbox: tool calls run with the current user's permissions,
+and absolute file paths are accepted. Shell command allow/deny policies can
+be supplied with repeatable CLI options. Destructive-command confirmation is
+available in the interactive REPL:
+
+```sh
+./llsh --shell-allow git --shell-allow rg --shell-deny git\ push \
+ --confirm-destructive "Inspect the repository"
+```
+
+The destructive-command detector is heuristic; use an operating-system
+sandbox when commands must be restricted to a security boundary.
+
+Workspace and global agent configuration can be loaded from `.agents/` and
+`~/.agents/`. Supported content includes `agents.md`, `system-prompt.md`,
+`models.json`, MCP server configuration, skills, sub-agent profiles, tasks,
+and memories. MCP server commands are not started unless explicitly trusted:
+
+```sh
+./llsh --allow-mcp "Use the configured project tools"
+```
+
+MCP tools are exposed with qualified names of the form
+`mcp__<server-name-length>_<server-name>__<tool-name>`. Use that name in a
+sub-agent's tool allowlist.
+
+## Platform and release scope
+
+The build targets x86_64 Linux and uses the host glibc ABI. Although linked
+statically, glibc's name-service lookups can load NSS shared libraries at
+runtime; use the matching glibc runtime on the target system. HTTPS requests
+also require a usable system CA certificate bundle. Other platforms and
+architectures are not currently supported by the build.
+Third-party components and their license families are listed in
+[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
+
+## License
+
+The llsh project is licensed under the MIT License; see [`LICENSE`](LICENSE).
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
new file mode 100644
index 0000000..fa06b05
--- /dev/null
+++ b/RELEASE_NOTES.md
@@ -0,0 +1,34 @@
+# Release notes
+
+## 0.1.0
+
+Initial release of `llsh`, a static Linux terminal LLM agent.
+
+### Included
+
+* Interactive and one-shot CLI modes with OpenAI-compatible, Anthropic, and
+ Ollama providers.
+* Built-in file, shell, embedding, one-shot LLM, skill, memory-search, and
+ context-compaction tools.
+* `.agents/` support for model presets, system prompts, skills, sub-agents,
+ tasks, memories, and MCP configuration.
+* Session save/resume, transcript export, context management, and goal loops.
+* Bundled third-party sources, host GCC build support, and a static
+ x86_64 Linux executable.
+
+### Security and compatibility notes
+
+* MCP servers from `.agents/mcp.json` require explicit opt-in with
+ `--allow-mcp`.
+* MCP tools use qualified names of the form
+ `mcp__<server-name-length>_<server-name>__<tool-name>`.
+* Model tool execution enforces the active profile's tool allowlist and the
+ shell policy gate.
+* The executable targets x86_64 Linux and uses the host glibc ABI. Glibc name
+ service lookups may use NSS shared libraries at runtime; HTTPS requests also
+ require a usable system CA certificate bundle.
+
+### Verification
+
+A clean vendor-cache rebuild completed with `make test`. `make asan` also
+passed in the development environment.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..1c8fff9
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,26 @@
+# Third-party notices
+
+llsh bundles source archives and build inputs for the following components.
+Refer to the license files included in each upstream source archive for the
+full license terms.
+
+Component | Version | License
+--------- | ------- | -------
+musl libc | 1.2.5 | MIT
+zlib | 1.3.1 | zlib License
+cJSON | 1.7.19 | MIT
+OpenSSL | 3.3.2 | Apache-2.0
+curl | 8.10.1 | curl License (MIT-like)
+nlohmann/json | 3.12.0 | MIT
+GNU glibc static runtime | host-matched Fedora package | LGPL-2.1-or-later and additional package component licenses
+GNU libstdc++ static runtime | host-matched Fedora package | GPL-3.0-or-later with GCC Runtime Library Exception
+
+The build downloads matching Fedora `glibc-static` and `libstdc++-static`
+packages into the ignored `vendor/build/system-runtime/` directory; these RPMs
+are not bundled as source-tree inputs. Package metadata lists the complete
+license set. Source RPM NEVRAs are host-version-specific; obtain the matching
+`glibc` and `gcc` source RPMs from the configured Fedora source repositories
+for corresponding source and license texts.
+
+Older vendored musl and libstdc++ artifacts remain in `vendor/` for reference;
+the current Makefile does not use them.
diff --git a/include/clio.h b/include/clio.h
new file mode 100644
index 0000000..8899197
--- /dev/null
+++ b/include/clio.h
@@ -0,0 +1,1493 @@
+/* clio.h — minimal pseudo-TUI layout layer over escape.h.
+ *
+ * escape.h owns styling (SGR, cursor motion, clear, color); clio.h owns
+ * layout: where text goes, how it wraps and clips, and how a composed
+ * frame is blitted to the terminal with the minimal diff of escape
+ * sequences. Apps never emit raw escape codes themselves.
+ *
+ * Model:
+ * clio_term the real terminal: size, capabilities, raw mode, alt
+ * screen, cursor, bracketed-paste/kitty state, key queue,
+ * and the last flushed frame (for diffing).
+ * clio_buf an in-memory cell grid (the canvas). Apps compose frames
+ * here; nothing is written until clio_flush.
+ * clio_flush diff the canvas against the previous frame and blit only
+ * changed rows through escape.h. Non-tty (pipe, CI) falls
+ * back to plain text lines so piped output stays clean.
+ * input a pure, chunk-fed key decoder (testable without a tty),
+ * raw-mode key reading with a bounded queue, and a
+ * single-line editor (clio_readline) that renders into the
+ * canvas.
+ *
+ * Conventions: C99, header-only (static inline), no global state,
+ * extern "C", POSIX-first (termios/poll/TIOCGWINSZ) — Windows is a
+ * documented limitation, as in escape.h.
+ *
+ * Usage: #include "clio.h". The header is complete on its own; escape.h
+ * is pulled in for the styling layer. The esc_* function symbols need one
+ * defining TU with ESCAPE_IMPLEMENTATION (llshcpp's src/escape.c, or the
+ * test TU itself for zero-link builds).
+ *
+ * Layout is column-based: one cell per column, one code point per cell.
+ * Wide characters (width 2) occupy one cell; a wide character landing
+ * in the last column may be clipped by the terminal (v1 limitation).
+ *
+ * API:
+ * style: clio_style, clio_style_default, clio_style_eq
+ * text: clio_utf8_next_n, clio_utf8_enc, clio_cp_width,
+ * clio_text_width, clio_char_start, clio_char_end,
+ * clio_word_back
+ * canvas: clio_buf, clio_cell, clio_buf_init/free/clear,
+ * clio_buf_at, clio_buf_clear_region, clio_buf_fill_region,
+ * clio_buf_put, clio_buf_box, clio_buf_scroll_up
+ * keys: clio_key, CLIOK_*, clio_keydec, clio_keydec_init,
+ * clio_keydec_feed, clio_keydec_flush
+ * terminal: clio_term, clio_color_policy, clio_term_init,
+ * clio_term_resize, clio_term_raw_on/off,
+ * clio_term_alt_on/off, clio_term_cursor_hide/show,
+ * clio_term_paste_on/off, clio_term_kitty_on/off,
+ * clio_term_bell, clio_term_shutdown, clio_term_free
+ * output: clio_fstyle, clio_flush, clio_cursor
+ * input: clio_next_key, clio_next_key_stdin, clio_read_key,
+ * clio_readline
+ *
+ * Concurrency: no shared state; each clio_term is independent.
+ */
+#ifndef CLIO_H
+#define CLIO_H
+
+#include "escape.h"
+#include "type.h"
+
+#include <ctype.h>
+#include <limits.h>
+#include <poll.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <termios.h>
+#include <unistd.h>
+#include <wchar.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Style */
+/* ------------------------------------------------------------------ */
+
+/* Attribute bits (OR-combined into clio_style.attr). */
+enum {
+ CLIO_BOLD = 1u,
+ CLIO_DIM = 2u,
+ CLIO_ITALIC = 4u,
+ CLIO_UNDERLINE = 8u,
+ CLIO_REVERSE = 16u,
+ CLIO_STRIKE = 32u,
+};
+
+/* fg/bg: -1 = terminal default, otherwise a 0..255 palette index.
+ * attr: OR of CLIO_* bits. */
+typedef struct clio_style {
+ int fg;
+ int bg;
+ unsigned attr;
+} clio_style;
+
+static inline clio_style clio_style_default(void) {
+ clio_style s;
+ s.fg = -1;
+ s.bg = -1;
+ s.attr = 0;
+ return s;
+}
+
+static inline int clio_style_eq(const clio_style *a, const clio_style *b) {
+ return a->fg == b->fg && a->bg == b->bg && a->attr == b->attr;
+}
+
+/* ------------------------------------------------------------------ */
+/* Text (UTF-8 + display width) */
+/* ------------------------------------------------------------------ */
+
+/* Decode the next code point in [p, end); returns the byte length used.
+ * Truncated or invalid sequences still consume at least one byte. */
+static inline size_t clio_utf8_next_n(const char *p, const char *end, u32 *cp) {
+ unsigned char b = (unsigned char) p[0];
+ size_t n;
+ if (b < 0x80)
+ n = 1;
+ else if ((b & 0xE0) == 0xC0)
+ n = 2;
+ else if ((b & 0xF0) == 0xE0)
+ n = 3;
+ else if ((b & 0xF8) == 0xF0)
+ n = 4;
+ else {
+ *cp = 0;
+ return 1;
+ }
+ if ((size_t)(end - p) < n)
+ n = (size_t)(end - p);
+ u32 c = 0;
+ for (size_t i = 0; i < n; i++) {
+ unsigned char ch = (unsigned char) p[i];
+ unsigned int mask =
+ (i == 0) ? (n == 1 ? 0x7Fu : n == 2 ? 0x1Fu : n == 3 ? 0x0Fu : 0x07u)
+ : 0x3Fu;
+ c = (c << 6) | (u32)(ch & mask);
+ }
+ *cp = c;
+ return n;
+}
+
+static inline size_t clio_utf8_next(const char *s, u32 *cp) {
+ return clio_utf8_next_n(s, s + strlen(s), cp);
+}
+
+/* Encode a code point into out (at least 5 bytes). -1 on invalid input. */
+static inline int clio_utf8_enc(u32 cp, char *out) {
+ size_t n;
+ if (cp < 0x80)
+ n = 1;
+ else if (cp < 0x800)
+ n = 2;
+ else if (cp < 0x10000)
+ n = 3;
+ else if (cp <= 0x10FFFF)
+ n = 4;
+ else
+ return -1;
+ if (cp >= 0xD800 && cp <= 0xDFFF)
+ return -1;
+ unsigned char b[4];
+ switch (n) {
+ case 1:
+ b[0] = (unsigned char) cp;
+ break;
+ case 2:
+ b[0] = (unsigned char)(0xC0 | (cp >> 6));
+ b[1] = (unsigned char)(0x80 | (cp & 0x3F));
+ break;
+ case 3:
+ b[0] = (unsigned char)(0xE0 | (cp >> 12));
+ b[1] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F));
+ b[2] = (unsigned char)(0x80 | (cp & 0x3F));
+ break;
+ default:
+ b[0] = (unsigned char)(0xF0 | (cp >> 18));
+ b[1] = (unsigned char)(0x80 | ((cp >> 12) & 0x3F));
+ b[2] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F));
+ b[3] = (unsigned char)(0x80 | (cp & 0x3F));
+ break;
+ }
+ memcpy(out, b, n);
+ out[n] = '\0';
+ return (int) n;
+}
+
+/* Display width of a code point, locale-independent (wcwidth is not:
+ * glibc's C locale reports 0 for CJK, musl reports 2 — we must be the
+ * same everywhere). 0 = zero-width, 2 = wide (East Asian Wide/Fullwidth
+ * + common emoji), 1 = everything else. Simplified W=|F table. */
+static inline size_t clio_cp_width(u32 cp) {
+ if (cp == 0 || cp < 0x20 || (cp >= 0x7F && cp <= 0x9F))
+ return 0;
+ if (cp == 0xAD || cp == 0x200B || cp == 0x200C || cp == 0x200D ||
+ cp == 0x2060 || (cp >= 0x300 && cp <= 0x36F))
+ return 0;
+ if ((cp >= 0x1100 && cp <= 0x115F) || (cp >= 0x2E80 && cp <= 0x303E) ||
+ (cp >= 0x3041 && cp <= 0x33FF) || (cp >= 0x3400 && cp <= 0x4DBF) ||
+ (cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0xA000 && cp <= 0xA4CF) ||
+ (cp >= 0xA960 && cp <= 0xA97F) || (cp >= 0xAC00 && cp <= 0xD7A3) ||
+ (cp >= 0xF900 && cp <= 0xFAFF) || (cp >= 0xFE10 && cp <= 0xFE19) ||
+ (cp >= 0xFE30 && cp <= 0xFE6F) || (cp >= 0xFF00 && cp <= 0xFF60) ||
+ (cp >= 0xFFE0 && cp <= 0xFFE6) || (cp >= 0x1F300 && cp <= 0x1F64F) ||
+ (cp >= 0x1F900 && cp <= 0x1F9FF) || (cp >= 0x20000 && cp <= 0x3FFFD))
+ return 2;
+ return 1;
+}
+
+static inline size_t clio_text_width_n(const char *s, size_t n) {
+ size_t w = 0;
+ const char *p = s;
+ const char *end = s + n;
+ while (p < end) {
+ u32 cp;
+ size_t len = clio_utf8_next_n(p, end, &cp);
+ if (cp != 0)
+ w += clio_cp_width(cp);
+ p += len;
+ }
+ return w;
+}
+
+static inline size_t clio_text_width(const char *s) {
+ return clio_text_width_n(s, strlen(s));
+}
+
+/* Byte offset of the start of the code point containing pos. */
+static inline size_t clio_char_start(const char *s, size_t pos) {
+ if (pos == 0)
+ return 0;
+ size_t i = pos;
+ while (i > 0 && (((unsigned char) s[i]) & 0xC0) == 0x80)
+ i--;
+ return i;
+}
+
+/* Byte offset of the start of the code point immediately before pos
+ * (pos is a cursor position, i.e. a char boundary). */
+static inline size_t clio_char_before(const char *s, size_t pos) {
+ size_t i = pos;
+ if (i == 0)
+ return 0;
+ i--; /* last byte belongs to the previous code point */
+ while (i > 0 && (((unsigned char) s[i - 1]) & 0xC0) == 0x80)
+ i--;
+ return i;
+}
+
+/* Byte offset just past the code point at pos. */
+static inline size_t clio_char_end(const char *s, size_t len, size_t pos) {
+ if (pos >= len)
+ return len;
+ u32 cp;
+ size_t n = clio_utf8_next_n(s + pos, s + len, &cp);
+ return pos + n;
+}
+
+/* Byte offset of the start of the word before pos (ed-style Ctrl+W). */
+static inline size_t clio_word_back(const char *s, size_t pos) {
+ size_t i = pos;
+ while (i > 0 && s[i - 1] == ' ')
+ i--;
+ while (i > 0 && (isalnum((unsigned char) s[i - 1]) || s[i - 1] == '_'))
+ i--;
+ while (i > 0 && s[i - 1] == ' ')
+ i--;
+ return i;
+}
+
+/* ------------------------------------------------------------------ */
+/* Canvas (cell grid) */
+/* ------------------------------------------------------------------ */
+
+/* One column of a row. ch is a code point (0 = blank slot; bg/attr still
+ * apply). fg/bg of 0xFFFF mean "terminal default". A blank default cell
+ * is {0, 0xFFFF, 0xFFFF, 0}. */
+typedef struct clio_cell {
+ u32 ch;
+ u16 fg;
+ u16 bg;
+ u16 attr;
+} clio_cell;
+
+typedef struct clio_buf {
+ clio_cell *cells;
+ size_t rows;
+ size_t cols;
+} clio_buf;
+
+static inline u16 clio_style_u16(int v) {
+ return (u16)(v < 0 ? 0xFFFF : (v > 255 ? 255 : v));
+}
+
+static inline void clio_cell_set(clio_cell *c, u32 ch, clio_style st) {
+ c->ch = ch;
+ c->fg = clio_style_u16(st.fg);
+ c->bg = clio_style_u16(st.bg);
+ c->attr = (u16) st.attr;
+}
+
+static inline clio_style clio_cell_style(const clio_cell *c) {
+ clio_style s;
+ s.fg = (c->fg == 0xFFFF) ? -1 : (int) c->fg;
+ s.bg = (c->bg == 0xFFFF) ? -1 : (int) c->bg;
+ s.attr = c->attr;
+ return s;
+}
+
+static inline int clio_cell_eq(const clio_cell *a, const clio_cell *b) {
+ return memcmp(a, b, sizeof(*a)) == 0;
+}
+
+static inline int clio_buf_init(clio_buf *b, size_t rows, size_t cols) {
+ if (rows == 0)
+ rows = 1;
+ if (cols == 0)
+ cols = 1;
+ b->rows = rows;
+ b->cols = cols;
+ b->cells = (clio_cell *) calloc(rows * cols, sizeof(clio_cell));
+ if (!b->cells) {
+ b->rows = b->cols = 0;
+ return -1;
+ }
+ for (size_t i = 0; i < rows * cols; i++) {
+ b->cells[i].fg = 0xFFFF;
+ b->cells[i].bg = 0xFFFF;
+ }
+ return 0;
+}
+
+static inline void clio_buf_free(clio_buf *b) {
+ free(b->cells);
+ b->cells = NULL;
+ b->rows = b->cols = 0;
+}
+
+static inline void clio_buf_clear(clio_buf *b) {
+ for (size_t i = 0; i < b->rows * b->cols; i++) {
+ b->cells[i].ch = 0;
+ b->cells[i].attr = 0;
+ b->cells[i].fg = 0xFFFF;
+ b->cells[i].bg = 0xFFFF;
+ }
+}
+
+static inline clio_cell *clio_buf_at(clio_buf *b, size_t row, size_t col) {
+ if (row >= b->rows || col >= b->cols)
+ return NULL;
+ return &b->cells[row * b->cols + col];
+}
+
+static inline void clio_buf_clear_region(clio_buf *b, size_t r0, size_t c0,
+ size_t r1, size_t c1) {
+ if (r1 < r0 || c1 < c0 || r0 >= b->rows || c0 >= b->cols)
+ return;
+ if (r1 > b->rows - 1)
+ r1 = b->rows - 1;
+ if (c1 > b->cols - 1)
+ c1 = b->cols - 1;
+ for (size_t r = r0; r <= r1; r++)
+ for (size_t c = c0; c <= c1; c++) {
+ clio_cell *cell = &b->cells[r * b->cols + c];
+ cell->ch = 0;
+ cell->attr = 0;
+ cell->fg = 0xFFFF;
+ cell->bg = 0xFFFF;
+ }
+}
+
+static inline void clio_buf_fill_region(clio_buf *b, size_t r0, size_t c0,
+ size_t r1, size_t c1, u32 ch,
+ clio_style st) {
+ if (r1 < r0 || c1 < c0 || r0 >= b->rows || c0 >= b->cols)
+ return;
+ if (r1 > b->rows - 1)
+ r1 = b->rows - 1;
+ if (c1 > b->cols - 1)
+ c1 = b->cols - 1;
+ for (size_t r = r0; r <= r1; r++)
+ for (size_t c = c0; c <= c1; c++)
+ clio_cell_set(&b->cells[r * b->cols + c], ch, st);
+}
+
+/* Write text at (row, col); wraps onto the next row (when wrap != 0 and
+ * rows remain) and clips at the buffer edges. '\n' moves to the next row
+ * at column 0. Zero-width code points occupy no cell. Returns the number
+ * of bytes written (including newlines). */
+static inline size_t clio_buf_put(clio_buf *b, size_t row, size_t col,
+ const char *text, clio_style st, int wrap) {
+ size_t written = 0;
+ size_t len = strlen(text);
+ const char *p = text;
+ while (p < text + len) {
+ u32 cp;
+ size_t n = clio_utf8_next_n(p, text + len, &cp);
+ p += n;
+ if (cp == '\n') {
+ row++;
+ col = 0;
+ if (row >= b->rows)
+ break;
+ written += n;
+ continue;
+ }
+ size_t w = clio_cp_width(cp);
+ if (w == 0) {
+ written += n;
+ continue;
+ }
+ if (col + w > b->cols) {
+ if (!wrap || row + 1 >= b->rows)
+ break;
+ row++;
+ col = 0;
+ }
+ for (size_t i = 0; i < w; i++) {
+ if (col + i >= b->cols)
+ break;
+ clio_cell *cell = &b->cells[row * b->cols + (col + i)];
+ cell->ch = (i == 0) ? cp : 0;
+ cell->fg = clio_style_u16(st.fg);
+ cell->bg = clio_style_u16(st.bg);
+ cell->attr = (u16) st.attr;
+ }
+ col += w;
+ written += n;
+ }
+ return written;
+}
+
+/* Draw a single-character border (ch, e.g. U+2502) around the rectangle
+ * (r0, c0) with width w and height h. */
+static inline void clio_buf_box(clio_buf *b, size_t r0, size_t c0, size_t w,
+ size_t h, u32 ch, clio_style st) {
+ if (r0 >= b->rows || c0 >= b->cols || w == 0 || h == 0)
+ return;
+ if (w > b->cols - c0)
+ w = b->cols - c0;
+ if (h > b->rows - r0)
+ h = b->rows - r0;
+ for (size_t x = 0; x < w; x++) {
+ clio_buf_fill_region(b, r0, c0 + x, r0, c0 + x, ch, st);
+ clio_buf_fill_region(b, r0 + h - 1, c0 + x, r0 + h - 1, c0 + x, ch, st);
+ }
+ for (size_t y = 1; y + 1 < h; y++) {
+ clio_buf_fill_region(b, r0 + y, c0, r0 + y, c0, ch, st);
+ clio_buf_fill_region(b, r0 + y, c0 + w - 1, r0 + y, c0 + w - 1, ch, st);
+ }
+}
+
+/* Shift rows [r0, r1) up by one row within columns [c0, c1]; the bottom
+ * row becomes blank. */
+static inline void clio_buf_scroll_up(clio_buf *b, size_t r0, size_t c0,
+ size_t r1, size_t c1) {
+ if (r1 <= r0 || r0 >= b->rows || c0 >= b->cols)
+ return;
+ if (r1 > b->rows)
+ r1 = b->rows;
+ if (c1 > b->cols - 1)
+ c1 = b->cols - 1;
+ for (size_t r = r0; r + 1 <= r1; r++)
+ memcpy(&b->cells[r * b->cols + c0], &b->cells[(r + 1) * b->cols + c0],
+ (c1 - c0 + 1) * sizeof(clio_cell));
+ for (size_t c = c0; c <= c1; c++) {
+ clio_cell *cell = &b->cells[r1 * b->cols + c];
+ cell->ch = 0;
+ cell->attr = 0;
+ cell->fg = 0xFFFF;
+ cell->bg = 0xFFFF;
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Key types + pure decoder */
+/* ------------------------------------------------------------------ */
+
+typedef struct clio_key {
+ int kind;
+ u32 cp; /* CLIOK_CHAR: the code point */
+} clio_key;
+
+enum {
+ CLIOK_UNKNOWN = 0,
+ CLIOK_CHAR,
+ CLIOK_ENTER,
+ CLIOK_TAB,
+ CLIOK_BACKTAB,
+ CLIOK_BACKSPACE,
+ CLIOK_DELETE,
+ CLIOK_LEFT,
+ CLIOK_RIGHT,
+ CLIOK_UP,
+ CLIOK_DOWN,
+ CLIOK_HOME,
+ CLIOK_END,
+ CLIOK_PAGEUP,
+ CLIOK_PAGEDOWN,
+ CLIOK_ESC,
+ CLIOK_CTRL_A,
+ CLIOK_CTRL_C,
+ CLIOK_CTRL_D,
+ CLIOK_CTRL_E,
+ CLIOK_CTRL_K,
+ CLIOK_CTRL_L,
+ CLIOK_CTRL_U,
+ CLIOK_CTRL_W,
+ CLIOK_CTRL_Y,
+ CLIOK_CTRL_Z,
+ CLIOK_CTRL_G,
+};
+
+/* State machine for decoding raw terminal input into clio_key values.
+ * Pure: no I/O, no allocation. Feed chunks of any size (byte boundaries
+ * may split UTF-8 or escape sequences); state persists across feeds. */
+typedef struct clio_keydec {
+ int state; /* 0 ground, 1 escape, 2 csi, 3 ss3 */
+ int utf8_n;
+ int utf8_need;
+ u32 utf8[4];
+ int csi_n;
+ unsigned char csi[32];
+ int pasting;
+} clio_keydec;
+
+static inline void clio_keydec_init(clio_keydec *d) {
+ memset(d, 0, sizeof *d);
+}
+
+static inline int clio_kd_ground(clio_keydec *d, unsigned char b, clio_key *k) {
+ if (b == 0x1B) {
+ d->state = 1;
+ return 0;
+ }
+ if (b == 0x0D) {
+ k->kind = CLIOK_ENTER;
+ k->cp = 0;
+ return 1;
+ }
+ if (b == 0x0A) {
+ if (d->pasting) {
+ k->kind = CLIOK_CHAR;
+ k->cp = '\n';
+ return 1;
+ }
+ return 0; /* bare LF outside paste: dead key */
+ }
+ if (b == 0x7F || b == 0x08) {
+ k->kind = CLIOK_BACKSPACE;
+ k->cp = 0;
+ return 1;
+ }
+ if (b == 0x09) {
+ k->kind = CLIOK_TAB;
+ k->cp = 0;
+ return 1;
+ }
+ if (b < 0x20) {
+ switch (b) {
+ case 0x01u:
+ k->kind = CLIOK_CTRL_A;
+ break;
+ case 0x03u:
+ k->kind = CLIOK_CTRL_C;
+ d->pasting = 0; /* Ctrl+C interrupts a dangling paste */
+ break;
+ case 0x04u:
+ k->kind = CLIOK_CTRL_D;
+ break;
+ case 0x05u:
+ k->kind = CLIOK_CTRL_E;
+ break;
+ case 0x0Bu:
+ k->kind = CLIOK_CTRL_K;
+ break;
+ case 0x0Cu:
+ k->kind = CLIOK_CTRL_L;
+ break;
+ case 0x15u:
+ k->kind = CLIOK_CTRL_U;
+ break;
+ case 0x17u:
+ k->kind = CLIOK_CTRL_W;
+ break;
+ case 0x19u:
+ k->kind = CLIOK_CTRL_Y;
+ break;
+ case 0x1Au:
+ k->kind = CLIOK_CTRL_Z;
+ break;
+ case 0x07u:
+ k->kind = CLIOK_CTRL_G;
+ break;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ break;
+ }
+ k->cp = 0;
+ return 1;
+ }
+ if (b < 0x80) {
+ k->kind = CLIOK_CHAR;
+ k->cp = b;
+ return 1;
+ }
+ if (b >= 0xC0 && b < 0xF8) {
+ d->utf8_need = (b >= 0xF0) ? 4 : (b >= 0xE0) ? 3 : 2;
+ d->utf8[0] = b;
+ d->utf8_n = 1;
+ return 0;
+ }
+ if (d->utf8_n > 0 && b >= 0x80 && b < 0xC0) {
+ d->utf8[d->utf8_n++] = b;
+ if (d->utf8_n < d->utf8_need)
+ return 0;
+ int n = d->utf8_n;
+ d->utf8_n = 0;
+ u32 cp =
+ d->utf8[0] & (n == 2 ? 0x1Fu : n == 3 ? 0x0Fu : 0x07u);
+ for (int i = 1; i < n; i++)
+ cp = (cp << 6) | (d->utf8[i] & 0x3Fu);
+ k->kind = CLIOK_CHAR;
+ k->cp = cp;
+ return 1;
+ }
+ d->utf8_n = 0;
+ k->kind = CLIOK_UNKNOWN;
+ k->cp = b;
+ return 1;
+}
+
+static inline int clio_kd_escape(clio_keydec *d, unsigned char b, clio_key *k) {
+ d->state = 0;
+ if (b == '[') {
+ d->state = 2;
+ d->csi_n = 0;
+ return 0;
+ }
+ if (b == 'O') {
+ d->state = 3;
+ return 0;
+ }
+ if (b >= 0x20 && b <= 0x7E) {
+ k->kind = CLIOK_CHAR;
+ k->cp = b;
+ return 1;
+ }
+ k->kind = CLIOK_ESC;
+ k->cp = 0;
+ return 1;
+}
+
+static inline int clio_kd_csi(clio_keydec *d, unsigned char b, clio_key *k) {
+ if (d->csi_n < (int) sizeof d->csi)
+ d->csi[d->csi_n++] = b;
+ if ((b >= '0' && b <= '9') || b == ';' || b == '?' || b == '<' || b == '=' ||
+ b == '>')
+ return 0;
+ d->state = 0;
+ unsigned long num = 0;
+ int any = 0;
+ for (int i = 0; i < d->csi_n - 1; i++) {
+ unsigned char p = d->csi[i];
+ if (p < '0' || p > '9')
+ break;
+ if (any)
+ num = num * 10 + (unsigned long)(p - '0');
+ else
+ num = (unsigned long)(p - '0');
+ any = 1;
+ if (num > 0xFFFFu)
+ break;
+ }
+ d->csi_n = 0;
+ switch (b) {
+ case 'A':
+ k->kind = CLIOK_UP;
+ k->cp = 0;
+ return 1;
+ case 'B':
+ k->kind = CLIOK_DOWN;
+ k->cp = 0;
+ return 1;
+ case 'C':
+ k->kind = CLIOK_RIGHT;
+ k->cp = 0;
+ return 1;
+ case 'D':
+ k->kind = CLIOK_LEFT;
+ k->cp = 0;
+ return 1;
+ case 'H':
+ k->kind = CLIOK_HOME;
+ k->cp = 0;
+ return 1;
+ case 'F':
+ k->kind = CLIOK_END;
+ k->cp = 0;
+ return 1;
+ case 'Z':
+ k->kind = CLIOK_BACKTAB;
+ k->cp = 0;
+ return 1;
+ case '~':
+ switch (num) {
+ case 1u:
+ case 7u:
+ k->kind = CLIOK_HOME;
+ break;
+ case 3u:
+ k->kind = CLIOK_DELETE;
+ break;
+ case 4u:
+ case 8u:
+ k->kind = CLIOK_END;
+ break;
+ case 5u:
+ k->kind = CLIOK_PAGEUP;
+ break;
+ case 6u:
+ k->kind = CLIOK_PAGEDOWN;
+ break;
+ case 200u:
+ d->pasting = 1;
+ return 0;
+ case 201u:
+ d->pasting = 0;
+ return 0;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ break;
+ }
+ k->cp = 0;
+ return 1;
+ case 'u': /* kitty keyboard protocol */
+ switch (num) {
+ case 9u:
+ k->kind = CLIOK_TAB;
+ break;
+ case 12u:
+ k->kind = CLIOK_BACKSPACE;
+ break;
+ case 13u:
+ k->kind = CLIOK_ENTER;
+ break;
+ case 27u:
+ k->kind = CLIOK_ESC;
+ break;
+ case 127u:
+ k->kind = CLIOK_DELETE;
+ break;
+ case 0x2190u:
+ k->kind = CLIOK_LEFT;
+ break;
+ case 0x2191u:
+ k->kind = CLIOK_UP;
+ break;
+ case 0x2192u:
+ k->kind = CLIOK_RIGHT;
+ break;
+ case 0x2193u:
+ k->kind = CLIOK_DOWN;
+ break;
+ default:
+ k->cp = 0;
+ return 0;
+ }
+ k->cp = 0;
+ return 1;
+ case 'R': /* cursor position report: swallow */
+ case 'M':
+ case 'h':
+ case 'l':
+ case 'm':
+ return 0;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ k->cp = 0;
+ return 1;
+ }
+}
+
+static inline int clio_kd_ss3(clio_keydec *d, unsigned char b, clio_key *k) {
+ d->state = 0;
+ switch (b) {
+ case 'A':
+ k->kind = CLIOK_UP;
+ break;
+ case 'B':
+ k->kind = CLIOK_DOWN;
+ break;
+ case 'C':
+ k->kind = CLIOK_RIGHT;
+ break;
+ case 'D':
+ k->kind = CLIOK_LEFT;
+ break;
+ case 'H':
+ k->kind = CLIOK_HOME;
+ break;
+ case 'F':
+ k->kind = CLIOK_END;
+ break;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ break;
+ }
+ k->cp = 0;
+ return 1;
+}
+
+static inline int clio_kd_step(clio_keydec *d, unsigned char b, clio_key *k) {
+ switch (d->state) {
+ case 1:
+ return clio_kd_escape(d, b, k);
+ case 2:
+ return clio_kd_csi(d, b, k);
+ case 3:
+ return clio_kd_ss3(d, b, k);
+ default:
+ return clio_kd_ground(d, b, k);
+ }
+}
+
+/* Feed raw bytes; emits up to cap decoded keys into out; returns the
+ * number emitted. */
+static inline size_t clio_keydec_feed(clio_keydec *d, const unsigned char *in,
+ size_t n, clio_key *out, size_t cap) {
+ size_t emitted = 0;
+ for (size_t i = 0; i < n; i++) {
+ clio_key k;
+ if (clio_kd_step(d, in[i], &k) && emitted < cap)
+ out[emitted++] = k;
+ }
+ return emitted;
+}
+
+/* Emit a trailing bare ESC (state 1) as a key. 1 if emitted. */
+static inline int clio_keydec_flush(clio_keydec *d, clio_key *k) {
+ if (d->state != 1)
+ return 0;
+ d->state = 0;
+ k->kind = CLIOK_ESC;
+ k->cp = 0;
+ return 1;
+}
+
+/* ------------------------------------------------------------------ */
+/* Terminal state */
+/* ------------------------------------------------------------------ */
+
+#define CLIO_KEYQ 32
+
+typedef struct clio_term {
+ int is_tty;
+ int color; /* clio_color_policy() result */
+ size_t rows;
+ size_t cols;
+ int raw;
+ struct termios saved;
+ int alt;
+ int cursor_hidden;
+ int paste;
+ int kitty;
+ clio_keydec dec;
+ clio_key q[CLIO_KEYQ];
+ size_t qh;
+ size_t qt;
+ clio_buf last;
+ int has_last;
+} clio_term;
+
+/* CLICOLOR_FORCE (non-empty, not "0") wins; otherwise color only on a tty
+ * without NO_COLOR, TERM=dumb, or CI. */
+static inline int clio_color_policy(int tty) {
+ const char *force = getenv("CLICOLOR_FORCE");
+ if (force && force[0]) {
+ if (strcmp(force, "0") == 0)
+ return 0;
+ return 1;
+ }
+ if (!tty)
+ return 0;
+ if (getenv("NO_COLOR"))
+ return 0;
+ const char *term = getenv("TERM");
+ if (term && strcmp(term, "dumb") == 0)
+ return 0;
+ if (getenv("CI"))
+ return 0;
+ return 1;
+}
+
+static inline int clio_term_init(clio_term *t) {
+ memset(t, 0, sizeof *t);
+ t->is_tty = isatty(STDIN_FILENO) != 0 && isatty(STDOUT_FILENO) != 0;
+ t->color = clio_color_policy(t->is_tty);
+ t->rows = 24;
+ t->cols = 80;
+ struct winsize ws;
+ if (t->is_tty && ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 &&
+ ws.ws_row > 0 && ws.ws_col > 0) {
+ t->rows = (size_t) ws.ws_row;
+ t->cols = (size_t) ws.ws_col;
+ }
+ clio_keydec_init(&t->dec);
+ return 0;
+}
+
+/* Re-read the window size; invalidates the flushed-frame cache. -1 on
+ * failure (previous size kept). */
+static inline int clio_term_resize(clio_term *t) {
+ struct winsize ws;
+ if (!t->is_tty)
+ return -1;
+ if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) != 0)
+ return -1;
+ if (ws.ws_row == 0 || ws.ws_col == 0)
+ return -1;
+ t->rows = (size_t) ws.ws_row;
+ t->cols = (size_t) ws.ws_col;
+ clio_buf_free(&t->last);
+ t->has_last = 0;
+ return 0;
+}
+
+static inline int clio_term_raw_on(clio_term *t) {
+ if (t->raw || !t->is_tty)
+ return -1;
+ if (tcgetattr(STDIN_FILENO, &t->saved) != 0)
+ return -1;
+ struct termios raw = t->saved;
+ raw.c_iflag &= ~((tcflag_t)(BRKINT | ICRNL | INPCK | ISTRIP | IXON));
+ raw.c_lflag &= ~((tcflag_t)(ECHO | ICANON | IEXTEN | ISIG));
+ raw.c_oflag &= ~((tcflag_t) OPOST);
+ raw.c_cc[VMIN] = 1;
+ raw.c_cc[VTIME] = 0;
+ if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) != 0)
+ return -1;
+ t->raw = 1;
+ return 0;
+}
+
+static inline void clio_term_raw_off(clio_term *t) {
+ if (!t->raw)
+ return;
+ tcsetattr(STDIN_FILENO, TCSANOW, &t->saved);
+ t->raw = 0;
+}
+
+static inline void clio_term_alt_on(clio_term *t) {
+ if (t->alt)
+ return;
+ esc_falt_screen_on(stdout);
+ fflush(stdout);
+ t->alt = 1;
+ clio_buf_free(&t->last);
+ t->has_last = 0;
+}
+
+static inline void clio_term_alt_off(clio_term *t) {
+ if (!t->alt)
+ return;
+ esc_falt_screen_off(stdout);
+ fflush(stdout);
+ t->alt = 0;
+ clio_buf_free(&t->last);
+ t->has_last = 0;
+}
+
+static inline void clio_term_cursor_hide(clio_term *t) {
+ if (t->cursor_hidden)
+ return;
+ esc_fcursor_hide(stdout);
+ fflush(stdout);
+ t->cursor_hidden = 1;
+}
+
+static inline void clio_term_cursor_show(clio_term *t) {
+ if (!t->cursor_hidden)
+ return;
+ esc_fcursor_show(stdout);
+ fflush(stdout);
+ t->cursor_hidden = 0;
+}
+
+static inline void clio_term_paste_on(clio_term *t) {
+ if (t->paste)
+ return;
+ esc_fbracketed_paste_on(stdout);
+ fflush(stdout);
+ t->paste = 1;
+ t->dec.pasting = 1;
+}
+
+static inline void clio_term_paste_off(clio_term *t) {
+ if (!t->paste)
+ return;
+ esc_fbracketed_paste_off(stdout);
+ fflush(stdout);
+ t->paste = 0;
+ t->dec.pasting = 0;
+}
+
+static inline void clio_term_kitty_on(clio_term *t) {
+ if (t->kitty)
+ return;
+ esc_fkitty_on(stdout);
+ fflush(stdout);
+ t->kitty = 1;
+}
+
+static inline void clio_term_kitty_off(clio_term *t) {
+ if (!t->kitty)
+ return;
+ esc_fkitty_off(stdout);
+ fflush(stdout);
+ t->kitty = 0;
+}
+
+static inline void clio_term_bell(clio_term *t) {
+ (void) t;
+ esc_fbell(stdout);
+ fflush(stdout);
+}
+
+/* Restore everything clio_term turned on (idempotent). */
+static inline void clio_term_shutdown(clio_term *t) {
+ if (t->kitty)
+ clio_term_kitty_off(t);
+ if (t->paste)
+ clio_term_paste_off(t);
+ if (t->cursor_hidden)
+ clio_term_cursor_show(t);
+ if (t->alt)
+ clio_term_alt_off(t);
+ clio_term_raw_off(t);
+}
+
+static inline void clio_term_free(clio_term *t) {
+ clio_term_shutdown(t);
+ clio_buf_free(&t->last);
+ memset(t, 0, sizeof *t);
+}
+
+/* ------------------------------------------------------------------ */
+/* Output (diff flush) */
+/* ------------------------------------------------------------------ */
+
+/* Map a style onto the stream: reset, then attributes, then 256-color
+ * fg/bg. escape.h emits the codes; clio.h only decides what goes. */
+static inline void clio_fstyle(FILE *f, clio_style st) {
+ if (!f)
+ return;
+ fputs(ESC_RESET, f);
+ if (st.attr & CLIO_BOLD)
+ fputs(ESC_BOLD, f);
+ if (st.attr & CLIO_DIM)
+ fputs(ESC_DIM, f);
+ if (st.attr & CLIO_ITALIC)
+ fputs(ESC_ITALIC, f);
+ if (st.attr & CLIO_UNDERLINE)
+ fputs(ESC_UNDERLINE, f);
+ if (st.attr & CLIO_REVERSE)
+ fputs(ESC_REVERSE, f);
+ if (st.attr & CLIO_STRIKE)
+ fputs(ESC_STRIKETHROUGH, f);
+ if (st.fg >= 0)
+ esc_fcolor_fg_256(f, (u32)(st.fg & 0xFF));
+ if (st.bg >= 0)
+ esc_fcolor_bg_256(f, (u32)(st.bg & 0xFF));
+}
+
+/* Render one row: style runs + text. Blank cells print as spaces. */
+static inline void clio_emit_row(FILE *f, const clio_buf *b, size_t row) {
+ clio_style cur = clio_style_default();
+ for (size_t c = 0; c < b->cols; c++) {
+ const clio_cell *cell = &b->cells[row * b->cols + c];
+ clio_style st = clio_cell_style(cell);
+ if (!clio_style_eq(&st, &cur)) {
+ clio_fstyle(f, st);
+ cur = st;
+ }
+ if (cell->ch == 0) {
+ fputc(' ', f);
+ } else if (cell->ch < 0x80) {
+ fputc((int)(cell->ch & 0xFF), f);
+ } else {
+ char tmp[8];
+ if (clio_utf8_enc(cell->ch, tmp) > 0)
+ fputs(tmp, f);
+ else
+ fputc(' ', f);
+ }
+ }
+}
+
+/* Non-tty fallback: plain text lines, trailing blanks trimmed. */
+static inline void clio_flush_plain(const clio_buf *b) {
+ size_t cap = b->cols * 4 + 1;
+ char *row = (char *) malloc(cap);
+ if (!row)
+ return;
+ for (size_t r = 0; r < b->rows; r++) {
+ size_t n = 0;
+ for (size_t c = 0; c < b->cols; c++) {
+ const clio_cell *cell = &b->cells[r * b->cols + c];
+ if (cell->ch == 0) {
+ row[n++] = ' ';
+ } else if (cell->ch < 0x80) {
+ row[n++] = (char)(cell->ch & 0xFF);
+ } else {
+ int e = clio_utf8_enc(cell->ch, row + n);
+ if (e > 0)
+ n += (size_t) e;
+ else
+ row[n++] = ' ';
+ }
+ }
+ while (n > 0 && row[n - 1] == ' ')
+ n--;
+ fwrite(row, 1, n, stdout);
+ fputc('\n', stdout);
+ }
+ free(row);
+ fflush(stdout);
+}
+
+/* Blit the canvas to the terminal. TTY: diff against the previous frame,
+ * clear screen + full repaint on first flush or size change, otherwise
+ * only changed rows. Non-tty: plain text lines (no escapes, safe to pipe). */
+static inline void clio_flush(clio_term *t, const clio_buf *b) {
+ if (!b || !b->cells)
+ return;
+ if (!t->is_tty) {
+ clio_flush_plain(b);
+ return;
+ }
+ int full = 1;
+ if (t->has_last && t->last.rows == b->rows && t->last.cols == b->cols)
+ full = 0;
+ if (full) {
+ clio_buf_free(&t->last);
+ if (clio_buf_init(&t->last, b->rows, b->cols) != 0) {
+ t->has_last = 0;
+ return;
+ }
+ t->has_last = 1;
+ esc_fclear_screen(stdout);
+ for (size_t r = 0; r < b->rows; r++) {
+ esc_fcursor_position(stdout, 1, (u32)(r + 1));
+ clio_emit_row(stdout, b, r);
+ }
+ } else {
+ for (size_t r = 0; r < b->rows; r++) {
+ int same = 1;
+ for (size_t c = 0; c < b->cols; c++) {
+ if (!clio_cell_eq(&t->last.cells[r * b->cols + c],
+ &b->cells[r * b->cols + c])) {
+ same = 0;
+ break;
+ }
+ }
+ if (same)
+ continue;
+ esc_fcursor_position(stdout, 1, (u32)(r + 1));
+ clio_emit_row(stdout, b, r);
+ }
+ }
+ memcpy(t->last.cells, b->cells, b->rows * b->cols * sizeof(clio_cell));
+ fflush(stdout);
+}
+
+/* Move the visible cursor (0-based row/col, clamped to the terminal). */
+static inline void clio_cursor(clio_term *t, size_t row, size_t col) {
+ if (!t->is_tty)
+ return;
+ if (row > t->rows)
+ row = t->rows;
+ if (col > t->cols)
+ col = t->cols;
+ esc_fcursor_position(stdout, (u32)(col + 1), (u32)(row + 1));
+ fflush(stdout);
+}
+
+/* ------------------------------------------------------------------ */
+/* Key input */
+/* ------------------------------------------------------------------ */
+
+/* Key source: 1 + *k set on success, 0 on timeout/EOF. */
+typedef int (*clio_next_key)(void *ud, clio_key *k, int timeout_ms);
+
+static inline int clio_read_key(clio_term *t, clio_key *out, int timeout_ms) {
+ for (;;) {
+ if (t->qt != t->qh) {
+ *out = t->q[t->qh];
+ t->qh++;
+ if (t->qh == CLIO_KEYQ)
+ t->qh = 0;
+ return 1;
+ }
+ if (timeout_ms == 0)
+ return clio_keydec_flush(&t->dec, out);
+ struct pollfd pfd;
+ pfd.fd = STDIN_FILENO;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+ int pr = poll(&pfd, 1, timeout_ms);
+ if (pr <= 0)
+ return clio_keydec_flush(&t->dec, out);
+ unsigned char in[16];
+ ssize_t n = read(STDIN_FILENO, in, sizeof in);
+ if (n <= 0)
+ return clio_keydec_flush(&t->dec, out);
+ clio_key tmp[16];
+ size_t got = clio_keydec_feed(&t->dec, in, (size_t) n, tmp, 16);
+ for (size_t i = 0; i < got; i++) {
+ if (t->qt == t->qh)
+ return clio_keydec_flush(&t->dec, out);
+ size_t slot = t->qt;
+ if (slot == CLIO_KEYQ)
+ slot = 0;
+ if (slot == t->qh) {
+ t->qh++;
+ if (t->qh == CLIO_KEYQ)
+ t->qh = 0;
+ } /* queue full: drop the oldest */
+ t->q[slot] = tmp[i];
+ t->qt = (slot + 1 == CLIO_KEYQ) ? 0 : slot + 1;
+ }
+ }
+}
+
+static inline int clio_next_key_stdin(void *ud, clio_key *k, int timeout_ms) {
+ clio_term *t = (clio_term *) ud;
+ return clio_read_key(t, k, timeout_ms);
+}
+
+/* ------------------------------------------------------------------ */
+/* Single-line editor */
+/* ------------------------------------------------------------------ */
+
+static inline void *clio_xrealloc(void *p, size_t n) {
+ void *q = realloc(p, n ? n : 1);
+ if (!q) {
+ fprintf(stderr, "clio: out of memory\n");
+ abort();
+ }
+ return q;
+}
+
+typedef struct clio_str {
+ char *s;
+ size_t len;
+ size_t cap;
+} clio_str;
+
+static inline void clio_str_init(clio_str *x) {
+ x->s = NULL;
+ x->len = 0;
+ x->cap = 0;
+}
+
+static inline void clio_str_free(clio_str *x) {
+ free(x->s);
+ x->s = NULL;
+ x->len = x->cap = 0;
+}
+
+static inline void clio_str_ins(clio_str *x, size_t at, const char *s, size_t n) {
+ if (x->len + n + 1 > x->cap) {
+ size_t nc = x->cap ? x->cap : 16;
+ while (x->len + n + 1 > nc)
+ nc *= 2;
+ x->s = (char *) clio_xrealloc(x->s, nc);
+ x->cap = nc;
+ }
+ memmove(x->s + at + n, x->s + at, x->len - at);
+ memcpy(x->s + at, s, n);
+ x->len += n;
+ x->s[x->len] = '\0';
+}
+
+static inline void clio_str_del(clio_str *x, size_t at, size_t n) {
+ if (at > x->len)
+ at = x->len;
+ if (at + n > x->len)
+ n = x->len - at;
+ memmove(x->s + at, x->s + at + n, x->len - at - n);
+ x->len -= n;
+ x->s[x->len] = '\0';
+}
+
+/* Count visible (non-zero-width) code points in the first n bytes. */
+static inline size_t clio_visible_n(const char *s, size_t n) {
+ size_t c = 0;
+ const char *p = s;
+ const char *end = s + n;
+ while (p < end) {
+ u32 cp;
+ size_t len = clio_utf8_next_n(p, end, &cp);
+ if (cp != 0 && clio_cp_width(cp) > 0)
+ c++;
+ p += len;
+ }
+ return c;
+}
+
+/* Paint the prompt + text (echo, or one '*' per visible code point) into
+ * the canvas at row; returns the caret column.
+ *
+ * The input is ONE visual line: it never wraps. When the caret passes the
+ * right edge the window slides (bash/readline style) so the caret always
+ * stays visible, so backspace/left-arrow work across the "wrap". */
+static inline size_t clio_readline_paint(clio_buf *b, size_t row,
+ const char *prompt, clio_style pstyle,
+ const clio_str *txt, size_t caret,
+ clio_style tstyle, int echo) {
+ clio_buf_clear_region(b, row, 0, row, b->cols - 1);
+ size_t pw = prompt ? clio_text_width(prompt) : 0;
+ if (prompt && prompt[0])
+ clio_buf_put(b, row, 0, prompt, pstyle, 0);
+ size_t area = (pw < b->cols) ? b->cols - pw : 0;
+ if (txt->len == 0 || area == 0)
+ return pw;
+ if (echo) {
+ size_t cw = clio_text_width_n(txt->s, caret);
+ /* Window left edge (display cells): 0 unless the caret does not
+ * fit, in which case pin the caret to the last column. */
+ size_t L = (cw + 1 > area) ? cw - (area - 1) : 0;
+ /* Largest char boundary <= L (a wide char straddling L is kept
+ * whole, window starts at it). */
+ size_t off = 0, start = 0;
+ const char *p = txt->s;
+ const char *end = txt->s + txt->len;
+ while (p < end) {
+ u32 cp;
+ size_t n = clio_utf8_next_n(p, end, &cp);
+ size_t w = (cp != 0) ? clio_cp_width(cp) : 0;
+ if (off + w > L) {
+ start = (size_t)(p - txt->s); /* `off` is the last boundary <= L */
+ break;
+ }
+ off += w;
+ p += n;
+ }
+ clio_buf_put(b, row, pw, txt->s + start, tstyle, 0);
+ size_t c = pw + (cw - off);
+ if (c >= b->cols) /* wide char straddled the left edge */
+ c = b->cols - 1;
+ return c;
+ }
+ size_t total = clio_visible_n(txt->s, txt->len);
+ size_t cw = clio_visible_n(txt->s, caret);
+ size_t first = (cw + 1 > area) ? cw - (area - 1) : 0;
+ for (size_t i = first; i < total; i++)
+ clio_buf_put(b, row, pw + (i - first), "*", tstyle, 0);
+ return pw + (cw - first);
+}
+
+/* Single-line editor. Renders into ONE canvas row (no wrapping; long
+ * input scrolls to keep the caret visible); reads keys from nk (NULL
+ * = clio_next_key_stdin with ud = t). Editing: printable insert, Tab,
+ * backspace/delete, left/right/home/end, Ctrl+A/E/U/K/W, Ctrl+C and ESC
+ * cancel (NULL), Ctrl+D deletes (EOF/submit when empty), Ctrl+L repaints,
+ * Enter submits. Returns the input (caller frees) or NULL. */
+static inline char *clio_readline(clio_term *t, clio_buf *b, size_t row,
+ const char *prompt, clio_style pstyle,
+ clio_style tstyle, int echo,
+ clio_next_key nk, void *ud, int timeout_ms) {
+ if (!nk) {
+ nk = clio_next_key_stdin;
+ ud = t;
+ }
+ clio_str txt;
+ clio_str_init(&txt);
+ size_t caret = 0;
+
+ size_t col =
+ clio_readline_paint(b, row, prompt, pstyle, &txt, caret, tstyle, echo);
+ if (t->is_tty)
+ clio_cursor(t, row, col);
+
+ int quit = 0;
+ for (;;) {
+ clio_key k;
+ if (!nk(ud, &k, timeout_ms))
+ break; /* timeout / EOF / no key */
+ int done = 0;
+ switch (k.kind) {
+ case CLIOK_CHAR: {
+ char tmp[8];
+ int e = clio_utf8_enc(k.cp, tmp);
+ if (e > 0) {
+ clio_str_ins(&txt, caret, tmp, (size_t) e);
+ caret += (size_t) e;
+ }
+ break;
+ }
+ case CLIOK_TAB:
+ clio_str_ins(&txt, caret, "\t", 1);
+ caret++;
+ break;
+ case CLIOK_ENTER:
+ done = 1;
+ break;
+ case CLIOK_BACKSPACE:
+ if (caret > 0) {
+ size_t s = clio_char_before(txt.s, caret);
+ clio_str_del(&txt, s, caret - s);
+ caret = s;
+ }
+ break;
+ case CLIOK_DELETE:
+ if (caret < txt.len)
+ clio_str_del(&txt, caret,
+ clio_char_end(txt.s, txt.len, caret) - caret);
+ break;
+ case CLIOK_LEFT:
+ if (caret > 0)
+ caret = clio_char_before(txt.s, caret);
+ break;
+ case CLIOK_RIGHT:
+ if (caret < txt.len)
+ caret = clio_char_end(txt.s, txt.len, caret);
+ break;
+ case CLIOK_HOME:
+ case CLIOK_CTRL_A:
+ caret = 0;
+ break;
+ case CLIOK_END:
+ case CLIOK_CTRL_E:
+ caret = txt.len;
+ break;
+ case CLIOK_CTRL_U:
+ clio_str_del(&txt, 0, caret);
+ caret = 0;
+ break;
+ case CLIOK_CTRL_K:
+ clio_str_del(&txt, caret, txt.len - caret);
+ break;
+ case CLIOK_CTRL_W: {
+ size_t s = clio_word_back(txt.s, caret);
+ clio_str_del(&txt, s, caret - s);
+ caret = s;
+ break;
+ }
+ case CLIOK_CTRL_C:
+ quit = 1;
+ break;
+ case CLIOK_CTRL_D:
+ if (txt.len == 0)
+ done = 1;
+ else if (caret < txt.len)
+ clio_str_del(&txt, caret,
+ clio_char_end(txt.s, txt.len, caret) - caret);
+ break;
+ case CLIOK_CTRL_L:
+ if (t->is_tty) {
+ esc_fclear_screen(stdout);
+ esc_fcursor_position(stdout, 1, 1);
+ fflush(stdout);
+ }
+ break;
+ case CLIOK_ESC:
+ quit = 1;
+ break;
+ default:
+ break;
+ }
+ col = clio_readline_paint(b, row, prompt, pstyle, &txt, caret, tstyle, echo);
+ if (t->is_tty)
+ clio_cursor(t, row, col);
+ if (done || quit)
+ break;
+ }
+
+ if (quit || txt.len == 0) {
+ clio_str_free(&txt);
+ return NULL; /* cancelled, or submitted empty */
+ }
+ return txt.s; /* caller frees */
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* CLIO_H */
diff --git a/include/dotagents.h b/include/dotagents.h
new file mode 100644
index 0000000..659e156
--- /dev/null
+++ b/include/dotagents.h
@@ -0,0 +1,1282 @@
+/* dotagents.h — portable, header-only implementation of the .agents protocol.
+ *
+ * The .agents protocol (dotagentsprotocol.com) is an open directory
+ * convention for AI agent configuration: one `.agents/` directory holds
+ * MCP server config, agent guidelines (AGENTS.md-compatible), skills,
+ * sub-agent profiles, repeat tasks, persistent memories, and model presets —
+ * in plain, version-controllable files.
+ *
+ * This library is the read-only side: it parses and resolves two layers —
+ * global `~/.agents/` and workspace `./.agents/` — with workspace winning on
+ * conflicts. It does not write.
+ *
+ * Directory schema (per layer):
+ * agents.md instructions (AGENTS.md-compatible)
+ * system-prompt.md system prompt
+ * mcp.json MCP server configuration
+ * models.json model presets & provider keys
+ * speakmcp-settings.json general settings
+ * layouts/ui.json UI/layout prefs
+ * skills/<id>/skill.md skill definition
+ * agents/<id>/agent.md sub-agent profile (+ optional config.json)
+ * tasks/<id>/task.md repeat task definition
+ * memories/ persistent memory entries (markdown files)
+ * .backups/ ignored (write-side artifact)
+ *
+ * Merge rules (final = global + workspace, workspace wins):
+ * - agents.md / system-prompt.md: whole-file replacement (workspace wins)
+ * - models.json: `default` replaced; `presets` merged by preset name
+ * (workspace wins per name)
+ * - mcp.json: `mcpServers` merged by server name (workspace wins per name)
+ * - speakmcp-settings.json / layouts/ui.json: shallow-merged by top-level
+ * key (workspace wins per key)
+ * - skills / agents / tasks / memories: merged by id (workspace wins per id)
+ * - unknown files and malformed files: warned about, skipped (never fatal)
+ *
+ * Conformance (read-only):
+ * [x] agents.md, system-prompt.md, mcp.json, models.json
+ * [x] speakmcp-settings.json, layouts/ui.json
+ * [x] skills/, agents/ (+ config.json), tasks/, memories/
+ * [x] frontmatter: quoted values, CSV + JSON-array lists, bool, int
+ * [x] task `profileId`, agent `connection-type`
+ * [x] case-insensitive file discovery (AGENTS.MD, SKILL.md, ...)
+ * [x] deterministic id-sorted output for stable diffs
+ * [ ] write side (atomic writes, .backups/ rotation) — out of scope
+ * [ ] .dotagents bundles / Hub — out of scope
+ *
+ * Usage: #include "dotagents.h" in your code. Requires frontmatter.h in the
+ * same directory (or on the include path). Zero dependencies beyond
+ * frontmatter.h and libc. All functions are static inline; safe to include
+ * in any number of translation units.
+ *
+ * API:
+ * dotagents_load(out) load ~/.agents + ./.agents (cwd)
+ * dotagents_load_from(out, ws, global) load two explicit layer dirs
+ * dotagents_free(out) free everything
+ * dotagents_project_sections(d, cap) guidelines + skill index + memory note
+ * dotagents_compose_preamble(d, base, cap) base + sections
+ * dotagents_find_skill(d, id) skill or NULL
+ * dotagents_enabled_sub_agent(d, id) enabled sub-agent or NULL
+ * dotagents_mcp_config(d, name) raw per-server JSON or NULL
+ * models_file_get(models, name) preset or NULL
+ * models_file_free(models) free a models_file
+ * memory_importance_rank(m) 0 high, 1 medium, 2 else
+ * parse_frontmatter / doc_get / doc_free / fm_list_items ...
+ * from frontmatter.h (re-exported)
+ *
+ * Memory: all strings are owned by the `dotagents` struct until
+ * dotagents_free. Load never fails: missing layers and malformed files are
+ * recorded in `out->warnings` (or silently skipped when absent).
+ */
+#ifndef DOTAGENTS_H
+#define DOTAGENTS_H
+
+#include "frontmatter.h"
+
+#include <dirent.h>
+#include <strings.h>
+#include <sys/stat.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+typedef enum {
+ LAYER_GLOBAL,
+ LAYER_WORKSPACE,
+} layer;
+
+typedef struct {
+ char *id;
+ char *name;
+ char *description;
+ bool enabled;
+ char *body;
+ layer layer;
+} skill;
+
+typedef struct {
+ char *id;
+ char *name;
+ char *description;
+ char *role;
+ char *connection_type; /* `connection-type` frontmatter, or NULL */
+ bool enabled;
+ char *body;
+ char *config; /* raw JSON text, or NULL */
+ layer layer;
+} sub_agent;
+
+typedef struct {
+ char *id;
+ char *name;
+ char *profile_id; /* `profileId` frontmatter, or NULL */
+ unsigned long interval_minutes;
+ bool has_interval;
+ bool enabled;
+ bool run_on_startup;
+ char *body;
+ layer layer;
+} task_entry;
+
+typedef struct {
+ char *id;
+ char *title;
+ char *importance;
+ char **tags;
+ size_t tag_count;
+ char *summary;
+ char *body;
+ layer layer;
+} memory;
+
+typedef struct {
+ char *name;
+ char *provider;
+ char *model;
+ char *base_url;
+ char *api_key;
+ unsigned long max_turns;
+ bool has_max_turns;
+ unsigned long context_limit;
+ bool has_context_limit;
+} model_preset;
+
+typedef struct {
+ char *default_name;
+ model_preset *presets;
+ size_t preset_count;
+} models_file;
+
+typedef struct {
+ layer *found_layers;
+ char **found_paths;
+ size_t found_count;
+ fm_strlist warnings;
+ char *system_prompt;
+ char *agents_md;
+ char *settings_json; /* merged speakmcp-settings.json, or NULL */
+ char *layouts_json; /* merged layouts/ui.json, or NULL */
+ fm_json *settings; /* accumulated merge tree (transient) */
+ fm_json *layouts; /* accumulated merge tree (transient) */
+ models_file models;
+ bool has_models;
+ char **mcp_names;
+ char **mcp_configs; /* raw JSON per server */
+ size_t mcp_count;
+ skill *skills;
+ size_t skill_count;
+ sub_agent *sub_agents;
+ size_t sub_agent_count;
+ task_entry *tasks;
+ size_t task_count;
+ memory *memories;
+ size_t memory_count;
+} dotagents;
+
+/* qsort comparators (replace the old O(n^2) inline sorts) */
+typedef struct { char *name; char *path; } da_dirent;
+static int da_cmp_dirent(const void *a, const void *b) {
+ return strcmp(((const da_dirent *)a)->name, ((const da_dirent *)b)->name);
+}
+static int da_cmp_skill_id(const void *a, const void *b) {
+ return strcmp(((const skill *)a)->id, ((const skill *)b)->id);
+}
+static int da_cmp_subagent_id(const void *a, const void *b) {
+ return strcmp(((const sub_agent *)a)->id, ((const sub_agent *)b)->id);
+}
+static int da_cmp_task_id(const void *a, const void *b) {
+ return strcmp(((const task_entry *)a)->id, ((const task_entry *)b)->id);
+}
+static int da_cmp_memory_id(const void *a, const void *b) {
+ return strcmp(((const memory *)a)->id, ((const memory *)b)->id);
+}
+static int da_cmp_cstr(const void *a, const void *b) {
+ return strcmp(*(const char *const *)a, *(const char *const *)b);
+}
+
+/* ------------------------------------------------------------------ */
+/* Layer helpers */
+/* ------------------------------------------------------------------ */
+
+static inline const char *layer_short(layer l) {
+ return l == LAYER_GLOBAL ? "global" : "workspace";
+}
+
+static inline const char *layer_display(layer l) {
+ return l == LAYER_GLOBAL ? "~/.agents" : ".agents";
+}
+
+/* ------------------------------------------------------------------ */
+/* File I/O */
+/* ------------------------------------------------------------------ */
+
+static inline bool da_is_file(const char *p) {
+ struct stat st;
+ return stat(p, &st) == 0 && S_ISREG(st.st_mode);
+}
+
+static inline bool da_is_dir(const char *p) {
+ struct stat st;
+ return stat(p, &st) == 0 && S_ISDIR(st.st_mode);
+}
+
+/* Read a whole file. 0 on success, -1 on any failure. */
+static inline int da_read_file(const char *path, char **out, size_t *out_len) {
+ FILE *f = fopen(path, "rb");
+ if (!f)
+ return -1;
+ fm_buf b;
+ fm_buf_init(&b);
+ char chunk[8192];
+ size_t r;
+ while ((r = fread(chunk, 1, sizeof chunk, f)) > 0)
+ fm_buf_putn(&b, chunk, r);
+ int fe = ferror(f);
+ fclose(f);
+ if (fe) {
+ fm_buf_free(&b);
+ return -1;
+ }
+ size_t total = b.len;
+ if (out)
+ *out = fm_buf_steal(&b);
+ if (out_len)
+ *out_len = total;
+ return 0;
+}
+
+static inline bool da_dir_has_entry(const char *dir) {
+ DIR *d = opendir(dir);
+ if (!d)
+ return false;
+ bool has = readdir(d) != NULL;
+ closedir(d);
+ return has;
+}
+
+/* Find `{stem}.md` case-insensitively directly inside dir. */
+static inline char *da_find_md(const char *dir, const char *stem) {
+ char *want = fm_strfmt("%s.md", stem);
+ for (char *p = want; *p; p++)
+ *p = (char)tolower((unsigned char)*p);
+ DIR *d = opendir(dir);
+ if (!d) {
+ free(want);
+ return NULL;
+ }
+ char *result = NULL;
+ struct dirent *e;
+ while ((e = readdir(d))) {
+ if (strcasecmp(e->d_name, want) == 0) {
+ result = fm_strfmt("%s/%s", dir, e->d_name);
+ break;
+ }
+ }
+ closedir(d);
+ free(want);
+ return result;
+}
+
+/* Sorted subdirectories of `dir`: returns name+path arrays. */
+static inline void da_sub_dirs(const char *dir, char ***out_names,
+ char ***out_paths, size_t *out_count) {
+ *out_count = 0;
+ DIR *d = opendir(dir);
+ if (!d)
+ return;
+ size_t cap = 8, n = 0;
+ char **names = (char **)fm_xmalloc(cap * sizeof(char *));
+ char **paths = (char **)fm_xmalloc(cap * sizeof(char *));
+ struct dirent *e;
+ while ((e = readdir(d))) {
+ if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0)
+ continue;
+ char *path = fm_strfmt("%s/%s", dir, e->d_name);
+ if (!da_is_dir(path)) {
+ free(path);
+ continue;
+ }
+ if (n + 1 > cap) {
+ cap *= 2;
+ names = (char **)fm_xrealloc(names, cap * sizeof(char *));
+ paths = (char **)fm_xrealloc(paths, cap * sizeof(char *));
+ }
+ names[n] = fm_strdup(e->d_name);
+ paths[n] = path;
+ n++;
+ }
+ closedir(d);
+ /* sort by name (keep the parallel paths array in lockstep) */
+ if (n > 1) {
+ da_dirent *ents = (da_dirent *)fm_xmalloc(n * sizeof *ents);
+ for (size_t i = 0; i < n; i++) {
+ ents[i].name = names[i];
+ ents[i].path = paths[i];
+ }
+ qsort(ents, n, sizeof *ents, da_cmp_dirent);
+ for (size_t i = 0; i < n; i++) {
+ names[i] = ents[i].name;
+ paths[i] = ents[i].path;
+ }
+ free(ents);
+ }
+ *out_names = names;
+ *out_paths = paths;
+ *out_count = n;
+}
+
+/* Does this root hold any recognizable protocol content? */
+static inline bool da_has_protocol_content(const char *root) {
+ const char *stems[2] = {"agents", "system-prompt"};
+ for (size_t i = 0; i < 2; i++) {
+ char *md = da_find_md(root, stems[i]);
+ if (md) {
+ free(md);
+ return true;
+ }
+ }
+ const char *files[4] = {"models.json", "mcp.json",
+ "speakmcp-settings.json", "layouts/ui.json"};
+ for (size_t i = 0; i < 4; i++) {
+ char *p = fm_strfmt("%s/%s", root, files[i]);
+ bool has = da_is_file(p);
+ free(p);
+ if (has)
+ return true;
+ }
+ const char *subs[4] = {"skills", "agents", "tasks", "memories"};
+ for (size_t i = 0; i < 4; i++) {
+ char *p = fm_strfmt("%s/%s", root, subs[i]);
+ bool has = da_is_dir(p) && da_dir_has_entry(p);
+ free(p);
+ if (has)
+ return true;
+ }
+ return false;
+}
+
+/* ------------------------------------------------------------------ */
+/* Accumulator helpers */
+/* ------------------------------------------------------------------ */
+
+static inline void da_warn(dotagents *out, const char *fmt, ...) {
+ va_list ap;
+ va_start(ap, fmt);
+ char *msg = fm_vsnprintf(fmt, ap);
+ va_end(ap);
+ fm_sl_push(&out->warnings, msg);
+}
+
+static inline char *da_read_optional(const char *path, layer layer,
+ dotagents *out) {
+ char *raw = NULL;
+ size_t len = 0;
+ if (da_read_file(path, &raw, &len) != 0) {
+ struct stat st;
+ if (stat(path, &st) == 0) {
+ /* exists but unreadable */
+ const char *base = strrchr(path, '/');
+ da_warn(out, "%s: %s: read error", layer_display(layer),
+ base ? base + 1 : path);
+ }
+ free(raw);
+ return NULL;
+ }
+ /* whitespace-only: treat as absent */
+ size_t i = 0;
+ while (i < len && (raw[i] == ' ' || raw[i] == '\t' || raw[i] == '\n' ||
+ raw[i] == '\r'))
+ i++;
+ if (i == len) {
+ free(raw);
+ return NULL;
+ }
+ return raw;
+}
+
+/* ------------------------------------------------------------------ */
+/* Ingest */
+/* ------------------------------------------------------------------ */
+
+static inline void da_ingest_models_json(const char *root, layer layer,
+ dotagents *out) {
+ char *path = fm_strfmt("%s/models.json", root);
+ char *raw = da_read_optional(path, layer, out);
+ free(path);
+ if (!raw)
+ return;
+ fm_json *o = fm_json_parse(raw);
+ free(raw);
+ if (!o || o->type != FMJ_OBJECT) {
+ da_warn(out, "%s: models.json: invalid JSON; skipped", layer_short(layer));
+ fm_json_free(o);
+ return;
+ }
+ /* unknown field check (deny_unknown_fields) */
+ bool ok = true;
+ for (size_t i = 0; i < o->count; i++) {
+ const char *k = o->items[i] ? o->items[i]->key : NULL;
+ if (!k || (strcmp(k, "default") != 0 && strcmp(k, "presets") != 0)) {
+ ok = false;
+ break;
+ }
+ }
+ if (ok) {
+ const fm_json *def = fm_json_get(o, "default");
+ if (def && def->type == FMJ_STRING) {
+ free(out->models.default_name);
+ out->models.default_name = fm_strdup(def->str);
+ }
+ const fm_json *pres = fm_json_get(o, "presets");
+ if (pres && pres->type == FMJ_OBJECT) {
+ for (size_t pi = 0; pi < pres->count; pi++) {
+ const fm_json *pv = pres->items[pi];
+ if (!pv || pv->type != FMJ_OBJECT) {
+ da_warn(out, "%s: models.json: preset `%s` is not an "
+ "object; skipped",
+ layer_short(layer), pv && pv->key ? pv->key : "?");
+ continue;
+ }
+ const fm_json *bad = NULL;
+ for (size_t fi = 0; fi < pv->count; fi++) {
+ const char *fk = pv->items[fi] ? pv->items[fi]->key : NULL;
+ if (!fk ||
+ (strcmp(fk, "provider") != 0 &&
+ strcmp(fk, "model") != 0 &&
+ strcmp(fk, "base_url") != 0 &&
+ strcmp(fk, "api_key") != 0 &&
+ strcmp(fk, "max_turns") != 0 &&
+ strcmp(fk, "context_limit") != 0)) {
+ bad = pv->items[fi];
+ break;
+ }
+ }
+ if (bad) {
+ da_warn(out, "%s: models.json: preset `%s` has unknown "
+ "field `%s`; skipped",
+ layer_short(layer), pv->key ? pv->key : "?",
+ bad->key ? bad->key : "?");
+ continue;
+ }
+ model_preset mp;
+ memset(&mp, 0, sizeof mp);
+ mp.name = fm_strdup(pv->key ? pv->key : "");
+ const fm_json *s;
+ if ((s = fm_json_get(pv, "provider")) && s->type == FMJ_STRING)
+ mp.provider = fm_strdup(s->str);
+ if ((s = fm_json_get(pv, "model")) && s->type == FMJ_STRING)
+ mp.model = fm_strdup(s->str);
+ if ((s = fm_json_get(pv, "base_url")) && s->type == FMJ_STRING)
+ mp.base_url = fm_strdup(s->str);
+ if ((s = fm_json_get(pv, "api_key")) && s->type == FMJ_STRING)
+ mp.api_key = fm_strdup(s->str);
+ if ((s = fm_json_get(pv, "max_turns")) &&
+ s->type == FMJ_NUMBER) {
+ mp.max_turns = (unsigned long)s->num;
+ mp.has_max_turns = true;
+ }
+ if ((s = fm_json_get(pv, "context_limit")) &&
+ s->type == FMJ_NUMBER) {
+ mp.context_limit = (unsigned long)s->num;
+ mp.has_context_limit = true;
+ }
+ /* replace by name or append */
+ bool replaced = false;
+ for (size_t i = 0; i < out->models.preset_count; i++) {
+ if (strcmp(out->models.presets[i].name, pv->key) == 0) {
+ free(out->models.presets[i].name);
+ free(out->models.presets[i].provider);
+ free(out->models.presets[i].model);
+ free(out->models.presets[i].base_url);
+ free(out->models.presets[i].api_key);
+ out->models.presets[i] = mp;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->models.presets = (model_preset *)fm_xrealloc(
+ out->models.presets,
+ (out->models.preset_count + 1) *
+ sizeof(model_preset));
+ out->models.presets[out->models.preset_count++] = mp;
+ }
+ out->has_models = true;
+ }
+ }
+ } else {
+ da_warn(out, "%s: models.json: invalid JSON; skipped", layer_short(layer));
+ }
+ fm_json_free(o);
+}
+
+static inline void da_ingest_mcp(const char *root, layer layer,
+ dotagents *out) {
+ char *path = fm_strfmt("%s/mcp.json", root);
+ char *raw = da_read_optional(path, layer, out);
+ free(path);
+ if (!raw)
+ return;
+ fm_json *o = fm_json_parse(raw);
+ free(raw);
+ if (!o) {
+ da_warn(out, "%s: mcp.json: invalid JSON; skipped", layer_short(layer));
+ return;
+ }
+ const fm_json *servers = fm_json_get(o, "mcpServers");
+ if (!servers || servers->type != FMJ_OBJECT) {
+ da_warn(out, "%s: mcp.json: missing `mcpServers` object; skipped",
+ layer_short(layer));
+ fm_json_free(o);
+ return;
+ }
+ for (size_t i = 0; i < servers->count; i++) {
+ const fm_json *sv = servers->items[i];
+ if (!sv)
+ continue;
+ char *cfg = fm_json_serialize(sv);
+ bool replaced = false;
+ for (size_t j = 0; j < out->mcp_count; j++) {
+ if (strcmp(out->mcp_names[j], sv->key ? sv->key : "") == 0) {
+ free(out->mcp_configs[j]);
+ out->mcp_configs[j] = cfg;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->mcp_names =
+ (char **)fm_xrealloc(out->mcp_names,
+ (out->mcp_count + 1) * sizeof(char *));
+ out->mcp_configs =
+ (char **)fm_xrealloc(out->mcp_configs,
+ (out->mcp_count + 1) * sizeof(char *));
+ out->mcp_names[out->mcp_count] =
+ fm_strdup(sv->key ? sv->key : "");
+ out->mcp_configs[out->mcp_count] = cfg;
+ out->mcp_count++;
+ }
+ }
+ fm_json_free(o);
+}
+
+static inline void da_ingest_settings(const char *root, layer layer,
+ dotagents *out) {
+ char *path = fm_strfmt("%s/speakmcp-settings.json", root);
+ char *raw = da_read_optional(path, layer, out);
+ free(path);
+ if (!raw)
+ return;
+ fm_json *o = fm_json_parse(raw);
+ free(raw);
+ if (!o || o->type != FMJ_OBJECT) {
+ da_warn(out, "%s: speakmcp-settings.json: invalid JSON; skipped",
+ layer_short(layer));
+ fm_json_free(o);
+ return;
+ }
+ out->settings = fm_json_merge_owned(out->settings, o);
+}
+
+static inline void da_ingest_layouts(const char *root, layer layer,
+ dotagents *out) {
+ char *path = fm_strfmt("%s/layouts/ui.json", root);
+ char *raw = da_read_optional(path, layer, out);
+ free(path);
+ if (!raw)
+ return;
+ fm_json *o = fm_json_parse(raw);
+ free(raw);
+ if (!o || o->type != FMJ_OBJECT) {
+ da_warn(out, "%s: layouts/ui.json: invalid JSON; skipped",
+ layer_short(layer));
+ fm_json_free(o);
+ return;
+ }
+ out->layouts = fm_json_merge_owned(out->layouts, o);
+}
+
+static inline void da_ingest_skills(const char *root, layer layer,
+ dotagents *out) {
+ char **names = NULL, **paths = NULL;
+ size_t n = 0;
+ char *base = fm_strfmt("%s/skills", root);
+ da_sub_dirs(base, &names, &paths, &n);
+ free(base);
+ for (size_t i = 0; i < n; i++) {
+ char *md = da_find_md(paths[i], "skill");
+ if (!md) {
+ da_warn(out, "%s: skills/%s: missing skill.md; skipped",
+ layer_short(layer), names[i]);
+ continue;
+ }
+ char *text = NULL;
+ if (da_read_file(md, &text, NULL) != 0) {
+ da_warn(out, "%s: skills/%s: skill.md unreadable; skipped",
+ layer_short(layer), names[i]);
+ free(md);
+ continue;
+ }
+ doc d;
+ parse_frontmatter(text, &d);
+ free(text);
+
+ char *id = names[i];
+ const fm_value *v;
+ if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s)
+ id = v->s;
+ char *name = names[i];
+ if ((v = doc_get(&d, "name")) && v->kind == FM_STR)
+ name = v->s;
+ char *desc = "";
+ if ((v = doc_get(&d, "description")) && v->kind == FM_STR)
+ desc = v->s;
+ bool enabled = true;
+ if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL)
+ enabled = v->b;
+
+ skill s;
+ s.id = fm_strdup(id);
+ s.name = fm_strdup(name);
+ s.description = fm_strdup(desc);
+ s.enabled = enabled;
+ s.body = fm_strdup(d.body);
+ s.layer = layer;
+
+ bool replaced = false;
+ for (size_t j = 0; j < out->skill_count; j++) {
+ if (strcmp(out->skills[j].id, s.id) == 0) {
+ free(out->skills[j].id);
+ free(out->skills[j].name);
+ free(out->skills[j].description);
+ free(out->skills[j].body);
+ out->skills[j] = s;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->skills = (skill *)fm_xrealloc(
+ out->skills, (out->skill_count + 1) * sizeof(skill));
+ out->skills[out->skill_count++] = s;
+ }
+ doc_free(&d);
+ free(md);
+ }
+ for (size_t i = 0; i < n; i++) {
+ free(names[i]);
+ free(paths[i]);
+ }
+ free(names);
+ free(paths);
+ /* keep sorted by id */
+ if (out->skill_count > 1)
+ qsort(out->skills, out->skill_count, sizeof *out->skills,
+ da_cmp_skill_id);
+}
+
+static inline void da_ingest_sub_agents(const char *root, layer layer,
+ dotagents *out) {
+ char **names = NULL, **paths = NULL;
+ size_t n = 0;
+ char *base = fm_strfmt("%s/agents", root);
+ da_sub_dirs(base, &names, &paths, &n);
+ free(base);
+ for (size_t i = 0; i < n; i++) {
+ char *md = da_find_md(paths[i], "agent");
+ if (!md) {
+ da_warn(out, "%s: agents/%s: missing agent.md; skipped",
+ layer_short(layer), names[i]);
+ continue;
+ }
+ char *text = NULL;
+ if (da_read_file(md, &text, NULL) != 0) {
+ da_warn(out, "%s: agents/%s: agent.md unreadable; skipped",
+ layer_short(layer), names[i]);
+ free(md);
+ continue;
+ }
+ doc d;
+ parse_frontmatter(text, &d);
+ free(text);
+
+ /* optional config.json next to agent.md */
+ char *config = NULL;
+ char *cp = fm_strfmt("%s/config.json", paths[i]);
+ char *craw = NULL;
+ if (da_read_file(cp, &craw, NULL) == 0) {
+ size_t cl = strlen(craw);
+ size_t ci = 0;
+ while (ci < cl && (craw[ci] == ' ' || craw[ci] == '\t' ||
+ craw[ci] == '\n' || craw[ci] == '\r'))
+ ci++;
+ if (ci < cl) {
+ fm_json *cv = fm_json_parse(craw);
+ if (cv) {
+ config = fm_json_serialize(cv);
+ fm_json_free(cv);
+ } else {
+ da_warn(out, "%s: agents/%s/config.json: invalid JSON; "
+ "skipped",
+ layer_short(layer), names[i]);
+ }
+ }
+ free(craw);
+ }
+ free(cp);
+
+ char *id = names[i];
+ const fm_value *v;
+ if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s)
+ id = v->s;
+ char *name = names[i];
+ if ((v = doc_get(&d, "name")) && v->kind == FM_STR)
+ name = v->s;
+ char *desc = "";
+ if ((v = doc_get(&d, "description")) && v->kind == FM_STR)
+ desc = v->s;
+ char *role = "delegation-target";
+ if ((v = doc_get(&d, "role")) && v->kind == FM_STR)
+ role = v->s;
+ char *ctype = NULL;
+ if ((v = doc_get(&d, "connection-type")) && v->kind == FM_STR)
+ ctype = v->s;
+ bool enabled = true;
+ if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL)
+ enabled = v->b;
+
+ sub_agent a;
+ a.id = fm_strdup(id);
+ a.name = fm_strdup(name);
+ a.description = fm_strdup(desc);
+ a.role = fm_strdup(role);
+ a.connection_type = ctype ? fm_strdup(ctype) : NULL;
+ a.enabled = enabled;
+ a.body = fm_strdup(d.body);
+ a.config = config;
+ a.layer = layer;
+
+ bool replaced = false;
+ for (size_t j = 0; j < out->sub_agent_count; j++) {
+ if (strcmp(out->sub_agents[j].id, a.id) == 0) {
+ free(out->sub_agents[j].id);
+ free(out->sub_agents[j].name);
+ free(out->sub_agents[j].description);
+ free(out->sub_agents[j].role);
+ free(out->sub_agents[j].connection_type);
+ free(out->sub_agents[j].body);
+ free(out->sub_agents[j].config);
+ out->sub_agents[j] = a;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->sub_agents =
+ (sub_agent *)fm_xrealloc(out->sub_agents,
+ (out->sub_agent_count + 1) *
+ sizeof(sub_agent));
+ out->sub_agents[out->sub_agent_count++] = a;
+ }
+ doc_free(&d);
+ free(md);
+ }
+ for (size_t i = 0; i < n; i++) {
+ free(names[i]);
+ free(paths[i]);
+ }
+ free(names);
+ free(paths);
+ if (out->sub_agent_count > 1)
+ qsort(out->sub_agents, out->sub_agent_count, sizeof *out->sub_agents,
+ da_cmp_subagent_id);
+}
+
+static inline void da_ingest_tasks(const char *root, layer layer,
+ dotagents *out) {
+ char **names = NULL, **paths = NULL;
+ size_t n = 0;
+ char *base = fm_strfmt("%s/tasks", root);
+ da_sub_dirs(base, &names, &paths, &n);
+ free(base);
+ for (size_t i = 0; i < n; i++) {
+ char *md = da_find_md(paths[i], "task");
+ if (!md) {
+ da_warn(out, "%s: tasks/%s: missing task.md; skipped",
+ layer_short(layer), names[i]);
+ continue;
+ }
+ char *text = NULL;
+ if (da_read_file(md, &text, NULL) != 0) {
+ da_warn(out, "%s: tasks/%s: task.md unreadable; skipped",
+ layer_short(layer), names[i]);
+ free(md);
+ continue;
+ }
+ doc d;
+ parse_frontmatter(text, &d);
+ free(text);
+
+ char *id = names[i];
+ const fm_value *v;
+ if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s)
+ id = v->s;
+ char *name = names[i];
+ if ((v = doc_get(&d, "name")) && v->kind == FM_STR)
+ name = v->s;
+ char *pid = NULL;
+ if ((v = doc_get(&d, "profileId")) && v->kind == FM_STR && v->s &&
+ *v->s)
+ pid = v->s;
+
+ task_entry t;
+ memset(&t, 0, sizeof t);
+ t.id = fm_strdup(id);
+ t.name = fm_strdup(name);
+ t.profile_id = pid ? fm_strdup(pid) : NULL;
+ if ((v = doc_get(&d, "intervalMinutes")) && v->kind == FM_INT &&
+ v->i >= 0) {
+ t.interval_minutes = (unsigned long)v->i;
+ t.has_interval = true;
+ }
+ t.enabled = true;
+ if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL)
+ t.enabled = v->b;
+ t.run_on_startup = false;
+ if ((v = doc_get(&d, "runOnStartup")) && v->kind == FM_BOOL)
+ t.run_on_startup = v->b;
+ t.body = fm_strdup(d.body);
+ t.layer = layer;
+
+ bool replaced = false;
+ for (size_t j = 0; j < out->task_count; j++) {
+ if (strcmp(out->tasks[j].id, t.id) == 0) {
+ free(out->tasks[j].id);
+ free(out->tasks[j].name);
+ free(out->tasks[j].profile_id);
+ free(out->tasks[j].body);
+ out->tasks[j] = t;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->tasks =
+ (task_entry *)fm_xrealloc(out->tasks,
+ (out->task_count + 1) *
+ sizeof(task_entry));
+ out->tasks[out->task_count++] = t;
+ }
+ doc_free(&d);
+ free(md);
+ }
+ for (size_t i = 0; i < n; i++) {
+ free(names[i]);
+ free(paths[i]);
+ }
+ free(names);
+ free(paths);
+ if (out->task_count > 1)
+ qsort(out->tasks, out->task_count, sizeof *out->tasks,
+ da_cmp_task_id);
+}
+
+static inline void da_ingest_memories(const char *root, layer layer,
+ dotagents *out) {
+ char *dir = fm_strfmt("%s/memories", root);
+ DIR *d = opendir(dir);
+ if (!d) {
+ free(dir);
+ return;
+ }
+ /* collect .md files */
+ size_t cap = 8, n = 0;
+ char **files = (char **)fm_xmalloc(cap * sizeof(char *));
+ struct dirent *e;
+ while ((e = readdir(d))) {
+ char *p = fm_strfmt("%s/%s", dir, e->d_name);
+ if (!da_is_file(p)) {
+ free(p);
+ continue;
+ }
+ const char *dot = strrchr(e->d_name, '.');
+ if (!dot || strcasecmp(dot, ".md") != 0) {
+ free(p);
+ continue;
+ }
+ if (n + 1 > cap) {
+ cap *= 2;
+ files = (char **)fm_xrealloc(files, cap * sizeof(char *));
+ }
+ files[n++] = p;
+ }
+ closedir(d);
+ if (n > 1)
+ qsort(files, n, sizeof *files, da_cmp_cstr);
+ for (size_t i = 0; i < n; i++) {
+ const char *base = strrchr(files[i], '/');
+ base = base ? base + 1 : files[i];
+ const char *dot = strrchr(base, '.');
+ char *stem = fm_strndup(
+ base, dot ? (size_t)(dot - base) : strlen(base));
+ char *text = NULL;
+ if (da_read_file(files[i], &text, NULL) != 0) {
+ da_warn(out, "%s: memories/%s.md: unreadable; skipped",
+ layer_short(layer), stem);
+ free(stem);
+ continue;
+ }
+ doc d2;
+ parse_frontmatter(text, &d2);
+ free(text);
+
+ char *id = stem;
+ const fm_value *v;
+ if ((v = doc_get(&d2, "id")) && v->kind == FM_STR && v->s && *v->s)
+ id = v->s;
+ char *title = stem;
+ if ((v = doc_get(&d2, "title")) && v->kind == FM_STR)
+ title = v->s;
+ char *importance = "medium";
+ if ((v = doc_get(&d2, "importance")) && v->kind == FM_STR)
+ importance = v->s;
+ char *summary = "";
+ if ((v = doc_get(&d2, "content")) && v->kind == FM_STR)
+ summary = v->s;
+
+ memory m;
+ memset(&m, 0, sizeof m);
+ m.id = fm_strdup(id);
+ m.title = fm_strdup(title);
+ m.importance = fm_strdup(importance);
+ m.summary = fm_strdup(summary);
+ m.body = fm_strdup(d2.body);
+ m.layer = layer;
+ if ((v = doc_get(&d2, "tags"))) {
+ fm_strlist items;
+ fm_list_items(v, &items);
+ m.tags = (char **)fm_xmalloc(
+ (items.len ? items.len : 1) * sizeof(char *));
+ for (size_t k = 0; k < items.len; k++)
+ m.tags[k] = fm_strdup(items.items[k]);
+ m.tag_count = items.len;
+ fm_sl_free(&items);
+ }
+
+ bool replaced = false;
+ for (size_t j = 0; j < out->memory_count; j++) {
+ if (strcmp(out->memories[j].id, m.id) == 0) {
+ free(out->memories[j].id);
+ free(out->memories[j].title);
+ free(out->memories[j].importance);
+ free(out->memories[j].summary);
+ free(out->memories[j].body);
+ for (size_t k = 0; k < out->memories[j].tag_count; k++)
+ free(out->memories[j].tags[k]);
+ free(out->memories[j].tags);
+ out->memories[j] = m;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->memories =
+ (memory *)fm_xrealloc(out->memories,
+ (out->memory_count + 1) *
+ sizeof(memory));
+ out->memories[out->memory_count++] = m;
+ }
+ doc_free(&d2);
+ free(stem);
+ }
+ for (size_t i = 0; i < n; i++)
+ free(files[i]);
+ free(files);
+ free(dir);
+ if (out->memory_count > 1)
+ qsort(out->memories, out->memory_count, sizeof *out->memories,
+ da_cmp_memory_id);
+}
+
+static inline void da_ingest(const char *root, layer layer, dotagents *out) {
+ char *agents_md = da_find_md(root, "agents");
+ if (agents_md) {
+ char *text = da_read_optional(agents_md, layer, out);
+ if (text) {
+ free(out->agents_md);
+ out->agents_md = text;
+ }
+ free(agents_md);
+ }
+ char *sp = da_find_md(root, "system-prompt");
+ if (sp) {
+ char *text = da_read_optional(sp, layer, out);
+ if (text) {
+ free(out->system_prompt);
+ out->system_prompt = text;
+ }
+ free(sp);
+ }
+ da_ingest_models_json(root, layer, out);
+ da_ingest_mcp(root, layer, out);
+ da_ingest_settings(root, layer, out);
+ da_ingest_layouts(root, layer, out);
+ da_ingest_skills(root, layer, out);
+ da_ingest_sub_agents(root, layer, out);
+ da_ingest_tasks(root, layer, out);
+ da_ingest_memories(root, layer, out);
+}
+
+/* ------------------------------------------------------------------ */
+/* Public API */
+/* ------------------------------------------------------------------ */
+
+static inline void dotagents_free(dotagents *d) {
+ free(d->found_layers);
+ for (size_t i = 0; i < d->found_count; i++)
+ free(d->found_paths[i]);
+ free(d->found_paths);
+ fm_sl_free(&d->warnings);
+ free(d->system_prompt);
+ free(d->agents_md);
+ free(d->settings_json);
+ free(d->layouts_json);
+ fm_json_free(d->settings);
+ fm_json_free(d->layouts);
+ free(d->models.default_name);
+ for (size_t i = 0; i < d->models.preset_count; i++) {
+ free(d->models.presets[i].name);
+ free(d->models.presets[i].provider);
+ free(d->models.presets[i].model);
+ free(d->models.presets[i].base_url);
+ free(d->models.presets[i].api_key);
+ }
+ free(d->models.presets);
+ for (size_t i = 0; i < d->mcp_count; i++) {
+ free(d->mcp_names[i]);
+ free(d->mcp_configs[i]);
+ }
+ free(d->mcp_names);
+ free(d->mcp_configs);
+ for (size_t i = 0; i < d->skill_count; i++) {
+ free(d->skills[i].id);
+ free(d->skills[i].name);
+ free(d->skills[i].description);
+ free(d->skills[i].body);
+ }
+ free(d->skills);
+ for (size_t i = 0; i < d->sub_agent_count; i++) {
+ free(d->sub_agents[i].id);
+ free(d->sub_agents[i].name);
+ free(d->sub_agents[i].description);
+ free(d->sub_agents[i].role);
+ free(d->sub_agents[i].connection_type);
+ free(d->sub_agents[i].body);
+ free(d->sub_agents[i].config);
+ }
+ free(d->sub_agents);
+ for (size_t i = 0; i < d->task_count; i++) {
+ free(d->tasks[i].id);
+ free(d->tasks[i].name);
+ free(d->tasks[i].profile_id);
+ free(d->tasks[i].body);
+ }
+ free(d->tasks);
+ for (size_t i = 0; i < d->memory_count; i++) {
+ free(d->memories[i].id);
+ free(d->memories[i].title);
+ free(d->memories[i].importance);
+ for (size_t k = 0; k < d->memories[i].tag_count; k++)
+ free(d->memories[i].tags[k]);
+ free(d->memories[i].tags);
+ free(d->memories[i].summary);
+ free(d->memories[i].body);
+ }
+ free(d->memories);
+}
+
+static inline void dotagents_load_from(dotagents *out, const char *workspace,
+ const char *global) {
+ memset(out, 0, sizeof *out);
+ fm_sl_init(&out->warnings);
+ if (global && da_is_dir(global) && da_has_protocol_content(global)) {
+ da_ingest(global, LAYER_GLOBAL, out);
+ out->found_layers = (layer *)fm_xrealloc(
+ out->found_layers, (out->found_count + 1) * sizeof(layer));
+ out->found_paths = (char **)fm_xrealloc(
+ out->found_paths, (out->found_count + 1) * sizeof(char *));
+ out->found_layers[out->found_count] = LAYER_GLOBAL;
+ out->found_paths[out->found_count] = fm_strdup(global);
+ out->found_count++;
+ }
+ if (da_is_dir(workspace) && da_has_protocol_content(workspace)) {
+ da_ingest(workspace, LAYER_WORKSPACE, out);
+ out->found_layers = (layer *)fm_xrealloc(
+ out->found_layers, (out->found_count + 1) * sizeof(layer));
+ out->found_paths = (char **)fm_xrealloc(
+ out->found_paths, (out->found_count + 1) * sizeof(char *));
+ out->found_layers[out->found_count] = LAYER_WORKSPACE;
+ out->found_paths[out->found_count] = fm_strdup(workspace);
+ out->found_count++;
+ }
+ /* materialize the transient merge trees as stable JSON text */
+ if (out->settings) {
+ out->settings_json = fm_json_serialize(out->settings);
+ fm_json_free(out->settings);
+ out->settings = NULL;
+ }
+ if (out->layouts) {
+ out->layouts_json = fm_json_serialize(out->layouts);
+ fm_json_free(out->layouts);
+ out->layouts = NULL;
+ }
+}
+
+static inline void dotagents_load(dotagents *out) {
+ memset(out, 0, sizeof *out);
+ fm_sl_init(&out->warnings);
+ const char *home = getenv("HOME");
+ char *global = NULL;
+ if (home)
+ global = fm_strfmt("%s/.agents", home);
+ dotagents_load_from(out, ".agents", global);
+ free(global);
+}
+
+/* Sections appended to the preamble: guidelines + skill index + memories. */
+static inline char *dotagents_project_sections(const dotagents *d,
+ unsigned long cap) {
+ if (cap < 1)
+ cap = 1;
+ fm_buf out;
+ fm_buf_init(&out);
+ if (d->agents_md && d->agents_md[0] &&
+ d->agents_md[strcspn(d->agents_md, " \t\n\r")] != 0) {
+ fm_buf_push(&out, '\n');
+ /* trim end */
+ size_t l = strlen(d->agents_md);
+ while (l && (d->agents_md[l - 1] == '\n' || d->agents_md[l - 1] == '\r' ||
+ d->agents_md[l - 1] == ' '))
+ l--;
+ fm_buf_putn(&out, d->agents_md, l);
+ fm_buf_push(&out, '\n');
+ }
+ /* enabled skills */
+ size_t enabled_count = 0;
+ for (size_t i = 0; i < d->skill_count; i++)
+ if (d->skills[i].enabled)
+ enabled_count++;
+ if (enabled_count > 0) {
+ fm_buf_put(&out, "\n# Skills\n\n");
+ fm_buf_put(
+ &out,
+ "Skills available for this project. Load a skill's full "
+ "instructions with the `use_skill` tool (pass its id):\n");
+ size_t listed = 0;
+ for (size_t i = 0; i < d->skill_count && listed < cap; i++) {
+ if (!d->skills[i].enabled)
+ continue;
+ if (d->skills[i].description[0])
+ fm_buf_fmt(&out, "- %s: %s\n", d->skills[i].id,
+ d->skills[i].description);
+ else
+ fm_buf_fmt(&out, "- %s\n", d->skills[i].id);
+ listed++;
+ }
+ if (enabled_count > cap)
+ fm_buf_fmt(&out, "…and %lu more\n", enabled_count - cap);
+ }
+ if (d->memory_count > 0) {
+ fm_buf_fmt(
+ &out,
+ "\n# Memories\n\n%lu persistent memory entries are available "
+ "across sessions. Retrieve them with the `search_memories` tool "
+ "(keyword or tag query).\n",
+ d->memory_count);
+ }
+ return fm_buf_steal(&out);
+}
+
+static inline char *dotagents_compose_preamble(const dotagents *d,
+ const char *base,
+ unsigned long cap) {
+ char *sections = dotagents_project_sections(d, cap);
+ fm_buf b;
+ fm_buf_init(&b);
+ fm_buf_put(&b, base);
+ fm_buf_put(&b, sections);
+ free(sections);
+ return fm_buf_steal(&b);
+}
+
+static inline const sub_agent *dotagents_enabled_sub_agent(
+ const dotagents *d, const char *id) {
+ for (size_t i = 0; i < d->sub_agent_count; i++)
+ if (strcmp(d->sub_agents[i].id, id) == 0 && d->sub_agents[i].enabled)
+ return &d->sub_agents[i];
+ return NULL;
+}
+
+static inline const skill *dotagents_find_skill(const dotagents *d,
+ const char *id) {
+ for (size_t i = 0; i < d->skill_count; i++)
+ if (strcmp(d->skills[i].id, id) == 0)
+ return &d->skills[i];
+ return NULL;
+}
+
+static inline const char *dotagents_mcp_config(const dotagents *d,
+ const char *name) {
+ for (size_t i = 0; i < d->mcp_count; i++)
+ if (strcmp(d->mcp_names[i], name) == 0)
+ return d->mcp_configs[i];
+ return NULL;
+}
+
+static inline const model_preset *models_file_get(const models_file *m,
+ const char *name) {
+ for (size_t i = 0; i < m->preset_count; i++)
+ if (strcmp(m->presets[i].name, name) == 0)
+ return &m->presets[i];
+ return NULL;
+}
+
+static inline void models_file_free(models_file *m) {
+ free(m->default_name);
+ for (size_t i = 0; i < m->preset_count; i++) {
+ free(m->presets[i].name);
+ free(m->presets[i].provider);
+ free(m->presets[i].model);
+ free(m->presets[i].base_url);
+ free(m->presets[i].api_key);
+ }
+ free(m->presets);
+ m->presets = NULL;
+ m->preset_count = 0;
+}
+
+static inline int memory_importance_rank(const memory *m) {
+ fm_buf l;
+ fm_buf_init(&l);
+ for (const char *p = m->importance; *p; p++)
+ fm_buf_push(&l, (char)tolower((unsigned char)*p));
+ l.data[l.len] = '\0';
+ int r = 2;
+ if (strcmp(l.data, "high") == 0)
+ r = 0;
+ else if (strcmp(l.data, "medium") == 0 || strcmp(l.data, "normal") == 0)
+ r = 1;
+ fm_buf_free(&l);
+ return r;
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* DOTAGENTS_H */
diff --git a/include/escape.h b/include/escape.h
new file mode 100755
index 0000000..af616b3
--- /dev/null
+++ b/include/escape.h
@@ -0,0 +1,272 @@
+// escape.h - ESC escape code macros and functions
+//
+// Thread safety: Safe for concurrent use (no shared state). The macros
+// produce string literals; the functions write to the provided FILE*.
+// Portability: ANSI escape codes are not supported on Windows Console API
+// (use Windows 10+ VT mode or a translation layer).
+
+#ifndef ESCAPE_H
+#define ESCAPE_H
+
+#include "type.h"
+#include <stdio.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define CSI "\x1B["
+
+#define ESC_CURSOR_UP(n) CSI #n "A"
+void esc_cursor_up(u32 n);
+void esc_fcursor_up(FILE *, u32 n);
+
+#define ESC_CURSOR_DOWN(n) CSI #n "B"
+void esc_cursor_down(u32 n);
+void esc_fcursor_down(FILE *, u32 n);
+
+#define ESC_CURSOR_LEFT(n) CSI #n "D"
+void esc_cursor_left(u32 n);
+void esc_fcursor_left(FILE *, u32 n);
+
+#define ESC_CURSOR_RIGHT(n) CSI #n "C"
+void esc_cursor_right(u32 n);
+void esc_fcursor_right(FILE *, u32 n);
+
+#define ESC_CURSOR_LINE_NEXT(n) CSI #n "E"
+void esc_cursor_line_next(u32 n);
+void esc_fcursor_line_next(FILE *, u32 n);
+
+#define ESC_CURSOR_PREVIOUS(n) CSI #n "F"
+void esc_cursor_previous(u32 n);
+void esc_fcursor_previous(FILE *, u32 n);
+
+#define ESC_CURSOR_POSITION(col, row) CSI #row ";" #col "H"
+void esc_cursor_position(u32 col, u32 row);
+void esc_fcursor_position(FILE *, u32 col, u32 row);
+
+#define ESC_CURSOR_COLUMN_HOME CSI "G"
+
+#define ESC_CLEAR_SCREEN CSI "2J"
+void esc_clear_screen(void);
+void esc_fclear_screen(FILE *);
+
+#define ESC_CLEAR_SCREEN_START CSI "1J"
+void esc_clear_screen_start(void);
+void esc_fclear_screen_start(FILE *);
+
+#define ESC_CLEAR_SCREEN_END CSI "0J"
+void esc_clear_screen_end(void);
+void esc_fclear_screen_end(FILE *);
+
+#define ESC_CLEAR_LINE CSI "2K"
+void esc_clear_line(void);
+void esc_fclear_line(FILE *);
+
+#define ESC_CLEAR_LINE_START CSI "1K"
+void esc_clear_line_start(void);
+void esc_fclear_line_start(FILE *);
+
+#define ESC_CLEAR_LINE_END CSI "0K"
+void esc_clear_line_end(void);
+void esc_fclear_line_end(FILE *);
+
+#define ESC_SCROLL_UP(n) CSI #n "S"
+void esc_scroll_up(u32 n);
+void esc_fscroll_up(FILE *, u32 n);
+
+#define ESC_SCROLL_DOWN(n) CSI #n "T"
+void esc_scroll_down(u32 n);
+void esc_fscroll_down(FILE *, u32 n);
+
+#define ESC_SGR "m"
+
+#define ESC_RESET CSI "0" ESC_SGR
+#define ESC_BOLD CSI "1" ESC_SGR
+#define ESC_DIM CSI "2" ESC_SGR
+#define ESC_ITALIC CSI "3" ESC_SGR
+#define ESC_UNDERLINE CSI "4" ESC_SGR
+#define ESC_BLINK CSI "5" ESC_SGR
+#define ESC_REVERSE CSI "7" ESC_SGR
+#define ESC_CONCEAL CSI "8" ESC_SGR
+#define ESC_REVEAL CSI "28" ESC_SGR
+#define ESC_STRIKETHROUGH CSI "9" ESC_SGR
+#define ESC_OVERLINE CSI "53" ESC_SGR
+
+#define ESC_COLOR_FG_RGB(r, g, b) CSI "38;2;" #r ";" #g ";" #b ESC_SGR
+void esc_color_fg_rgb(u32 r, u32 g, u32 b);
+void esc_fcolor_fg_rgb(FILE *, u32 r, u32 g, u32 b);
+
+#define ESC_COLOR_BG_RGB(r, g, b) CSI "48;2;" #r ";" #g ";" #b ESC_SGR
+void esc_color_bg_rgb(u32 r, u32 g, u32 b);
+void esc_fcolor_bg_rgb(FILE *, u32 r, u32 g, u32 b);
+
+#define ESC_COLOR_FG_256(n) CSI "38;5;" #n ESC_SGR
+void esc_color_fg_256(u32 n);
+void esc_fcolor_fg_256(FILE *, u32 n);
+
+#define ESC_COLOR_BG_256(n) CSI "48;5;" #n ESC_SGR
+void esc_color_bg_256(u32 n);
+void esc_fcolor_bg_256(FILE *, u32 n);
+
+#define ESC_COLOR_FG(n) CSI "3" #n ESC_SGR
+#define ESC_COLOR_BG(n) CSI "4" #n ESC_SGR
+#define ESC_COLOR_FG_BRIGHT(n) CSI "9" #n ESC_SGR
+#define ESC_COLOR_BG_BRIGHT(n) CSI "10" #n ESC_SGR
+
+/* Named standard colors (ANSI 0-7). */
+#define ESC_COLOR_BLACK ESC_COLOR_FG(0)
+#define ESC_COLOR_RED ESC_COLOR_FG(1)
+#define ESC_COLOR_GREEN ESC_COLOR_FG(2)
+#define ESC_COLOR_YELLOW ESC_COLOR_FG(3)
+#define ESC_COLOR_BLUE ESC_COLOR_FG(4)
+#define ESC_COLOR_MAGENTA ESC_COLOR_FG(5)
+#define ESC_COLOR_CYAN ESC_COLOR_FG(6)
+#define ESC_COLOR_WHITE ESC_COLOR_FG(7)
+
+#define ESC_CURSOR_HIDE CSI "?25l"
+void esc_cursor_hide(void);
+void esc_fcursor_hide(FILE *);
+
+#define ESC_CURSOR_SHOW CSI "?25h"
+void esc_cursor_show(void);
+void esc_fcursor_show(FILE *);
+
+#define ESC_CURSOR_SAVE CSI "s"
+void esc_cursor_save(void);
+void esc_fcursor_save(FILE *);
+
+#define ESC_CURSOR_RESTORE CSI "u"
+void esc_cursor_restore(void);
+void esc_fcursor_restore(FILE *);
+
+#define ESC_BELL "\x07"
+void esc_bell(void);
+void esc_fbell(FILE *);
+
+#define ESC_ALT_SCREEN_ON CSI "?1049h"
+void esc_alt_screen_on(void);
+void esc_falt_screen_on(FILE *);
+
+#define ESC_ALT_SCREEN_OFF CSI "?1049l"
+void esc_alt_screen_off(void);
+void esc_falt_screen_off(FILE *);
+
+#define ESC_BRACKETED_PASTE_ON CSI "?2004h"
+void esc_bracketed_paste_on(void);
+void esc_fbracketed_paste_on(FILE *);
+
+#define ESC_BRACKETED_PASTE_OFF CSI "?2004l"
+void esc_bracketed_paste_off(void);
+void esc_fbracketed_paste_off(FILE *);
+
+#define ESC_KITTY_ON CSI ">1u"
+void esc_kitty_on(void);
+void esc_fkitty_on(FILE *);
+
+#define ESC_KITTY_OFF CSI ">0u"
+void esc_kitty_off(void);
+void esc_fkitty_off(FILE *);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // ESCAPE_H
+
+#ifdef ESCAPE_IMPLEMENTATION
+
+#include <stdio.h>
+
+void esc_fcursor_up(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uA", (unsigned)n); }
+void esc_fcursor_down(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uB", (unsigned)n); }
+void esc_fcursor_left(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uD", (unsigned)n); }
+void esc_fcursor_right(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uC", (unsigned)n); }
+void esc_fcursor_line_next(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uE", (unsigned)n); }
+void esc_fcursor_previous(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uF", (unsigned)n); }
+void esc_fcursor_position(FILE *f, u32 col, u32 row) {
+ if (!f) return;
+ fprintf(f, CSI "%u;%uH", (unsigned)row, (unsigned)col);
+}
+void esc_fclear_screen(FILE *f) { if (!f) return; fputs(CSI "2J", f); }
+void esc_fclear_screen_start(FILE *f) { if (!f) return; fputs(CSI "1J", f); }
+void esc_fclear_screen_end(FILE *f) { if (!f) return; fputs(CSI "0J", f); }
+void esc_fclear_line(FILE *f) { if (!f) return; fputs(CSI "2K", f); }
+void esc_fclear_line_start(FILE *f) { if (!f) return; fputs(CSI "1K", f); }
+void esc_fclear_line_end(FILE *f) { if (!f) return; fputs(CSI "0K", f); }
+void esc_fscroll_up(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uS", (unsigned)n); }
+void esc_fscroll_down(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uT", (unsigned)n); }
+
+void esc_fcolor_fg_rgb(FILE *f, u32 r, u32 g, u32 b) {
+ if (!f) return;
+ fprintf(f, CSI "38;2;%u;%u;%u" ESC_SGR, (unsigned)r, (unsigned)g, (unsigned)b);
+}
+void esc_fcolor_bg_rgb(FILE *f, u32 r, u32 g, u32 b) {
+ if (!f) return;
+ fprintf(f, CSI "48;2;%u;%u;%u" ESC_SGR, (unsigned)r, (unsigned)g, (unsigned)b);
+}
+void esc_fcolor_fg_256(FILE *f, u32 n) {
+ if (!f) return;
+ fprintf(f, CSI "38;5;%u" ESC_SGR, (unsigned)n);
+}
+void esc_fcolor_bg_256(FILE *f, u32 n) {
+ if (!f) return;
+ fprintf(f, CSI "48;5;%u" ESC_SGR, (unsigned)n);
+}
+
+void esc_fcursor_hide(FILE *f) { if (!f) return; fputs(CSI "?25l", f); }
+void esc_fcursor_show(FILE *f) { if (!f) return; fputs(CSI "?25h", f); }
+void esc_fcursor_save(FILE *f) { if (!f) return; fputs(CSI "s", f); }
+void esc_fcursor_restore(FILE *f) { if (!f) return; fputs(CSI "u", f); }
+void esc_fbell(FILE *f) { if (!f) return; fputc('\a', f); }
+void esc_falt_screen_on(FILE *f) { if (!f) return; fputs(CSI "?1049h", f); }
+void esc_falt_screen_off(FILE *f) { if (!f) return; fputs(CSI "?1049l", f); }
+void esc_fbracketed_paste_on(FILE *f) { if (!f) return; fputs(CSI "?2004h", f); }
+void esc_fbracketed_paste_off(FILE *f) { if (!f) return; fputs(CSI "?2004l", f); }
+void esc_fkitty_on(FILE *f) { if (!f) return; fputs(CSI ">1u", f); }
+void esc_fkitty_off(FILE *f) { if (!f) return; fputs(CSI ">0u", f); }
+
+void esc_cursor_up(u32 n) { esc_fcursor_up(stdout, n); }
+void esc_cursor_down(u32 n) { esc_fcursor_down(stdout, n); }
+void esc_cursor_left(u32 n) { esc_fcursor_left(stdout, n); }
+void esc_cursor_right(u32 n) { esc_fcursor_right(stdout, n); }
+void esc_cursor_line_next(u32 n) { esc_fcursor_line_next(stdout, n); }
+void esc_cursor_previous(u32 n) { esc_fcursor_previous(stdout, n); }
+void esc_cursor_position(u32 col, u32 row) {
+ esc_fcursor_position(stdout, col, row);
+}
+void esc_clear_screen(void) { esc_fclear_screen(stdout); }
+void esc_clear_screen_start(void) { esc_fclear_screen_start(stdout); }
+void esc_clear_screen_end(void) { esc_fclear_screen_end(stdout); }
+void esc_clear_line(void) { esc_fclear_line(stdout); }
+void esc_clear_line_start(void) { esc_fclear_line_start(stdout); }
+void esc_clear_line_end(void) { esc_fclear_line_end(stdout); }
+void esc_scroll_up(u32 n) { esc_fscroll_up(stdout, n); }
+void esc_scroll_down(u32 n) { esc_fscroll_down(stdout, n); }
+
+void esc_color_fg_rgb(u32 r, u32 g, u32 b) {
+ esc_fcolor_fg_rgb(stdout, r, g, b);
+}
+void esc_color_bg_rgb(u32 r, u32 g, u32 b) {
+ esc_fcolor_bg_rgb(stdout, r, g, b);
+}
+void esc_color_fg_256(u32 n) {
+ esc_fcolor_fg_256(stdout, n);
+}
+void esc_color_bg_256(u32 n) {
+ esc_fcolor_bg_256(stdout, n);
+}
+
+void esc_cursor_hide(void) { esc_fcursor_hide(stdout); }
+void esc_cursor_show(void) { esc_fcursor_show(stdout); }
+void esc_cursor_save(void) { esc_fcursor_save(stdout); }
+void esc_cursor_restore(void) { esc_fcursor_restore(stdout); }
+void esc_bell(void) { esc_fbell(stdout); }
+void esc_alt_screen_on(void) { esc_falt_screen_on(stdout); }
+void esc_alt_screen_off(void) { esc_falt_screen_off(stdout); }
+void esc_bracketed_paste_on(void) { esc_fbracketed_paste_on(stdout); }
+void esc_bracketed_paste_off(void) { esc_fbracketed_paste_off(stdout); }
+void esc_kitty_on(void) { esc_fkitty_on(stdout); }
+void esc_kitty_off(void) { esc_fkitty_off(stdout); }
+
+#endif // ESCAPE_IMPLEMENTATION \ No newline at end of file
diff --git a/include/frontmatter.h b/include/frontmatter.h
new file mode 100644
index 0000000..56c6935
--- /dev/null
+++ b/include/frontmatter.h
@@ -0,0 +1,1058 @@
+/* frontmatter.h — portable, header-only markdown frontmatter library.
+ *
+ * Parses a markdown document with an optional `---` fenced frontmatter block
+ * into typed values. Zero dependencies: libc only. Not full YAML: simple
+ * `key: value` lines. Values may be quoted (`"..."` or `'...'`). List fields
+ * accept CSV (`tags: a, b, c`) or JSON arrays (`tags: ["a", "b"]`).
+ *
+ * Format:
+ * - Frontmatter is delimited by a leading `---` line and a closing `---`
+ * line. An unterminated fence (no closing `---`) means the whole text is
+ * body — no metadata.
+ * - Lines are `key: value`. Blank lines, `#` comments, and lines without a
+ * colon are skipped. Keys and values are trimmed.
+ * - Value types: `true`/`false` → bool; bare integer → int; `[...]` → list
+ * (JSON array; non-string elements are serialized); anything else →
+ * string (quotes stripped if present).
+ * - The body is everything after the closing fence, trailing whitespace
+ * trimmed. Documents without frontmatter: whole text, trailing
+ * whitespace trimmed.
+ *
+ * Usage: #include "frontmatter.h" in your code. The header is complete on
+ * its own; all functions are static inline, safe to include in any number
+ * of translation units.
+ *
+ * API:
+ * parse_frontmatter(text, &doc) split frontmatter + body
+ * doc_get(&doc, "key") value or NULL
+ * doc_free(&doc) free the document
+ * fm_value_free(&val) free one value
+ * fm_list_items(&val, &items) list-valued field as string items
+ * fm_json_parse(text) embedded JSON value or NULL (invalid)
+ * fm_json_free(v) / fm_json_get(obj, key) / fm_json_serialize(v)
+ * fm_json_merge_owned(base, over) shallow object merge; owns both inputs
+ *
+ * Concurrency: no shared state. Each call is self-contained.
+ */
+#ifndef FRONTMATTER_H
+#define FRONTMATTER_H
+
+#include <ctype.h>
+#include <errno.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Allocation */
+/* ------------------------------------------------------------------ */
+
+static inline void *fm_xmalloc(size_t n) {
+ void *p = malloc(n ? n : 1);
+ if (!p) {
+ fprintf(stderr, "frontmatter: out of memory\n");
+ abort();
+ }
+ return p;
+}
+
+static inline void *fm_xrealloc(void *p, size_t n) {
+ void *q = realloc(p, n ? n : 1);
+ if (!q) {
+ fprintf(stderr, "frontmatter: out of memory\n");
+ abort();
+ }
+ return q;
+}
+
+static inline char *fm_strdup(const char *s) {
+ size_t n = s ? strlen(s) : 0;
+ char *p = (char *)fm_xmalloc(n + 1);
+ if (s)
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+static inline char *fm_strndup(const char *s, size_t n) {
+ char *p = (char *)fm_xmalloc(n + 1);
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+static inline char *fm_vsnprintf(const char *fmt, va_list ap) {
+ va_list ap2;
+ va_copy(ap2, ap);
+ int n = vsnprintf(NULL, 0, fmt, ap2);
+ va_end(ap2);
+ if (n < 0)
+ return fm_strdup("");
+ char *p = (char *)fm_xmalloc((size_t)n + 1);
+ vsnprintf(p, (size_t)n + 1, fmt, ap);
+ return p;
+}
+
+static inline char *fm_strfmt(const char *fmt, ...) {
+ va_list ap;
+ va_start(ap, fmt);
+ char *s = fm_vsnprintf(fmt, ap);
+ va_end(ap);
+ return s;
+}
+
+/* ------------------------------------------------------------------ */
+/* Growable byte buffer (NUL-terminated on steal) */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ char *data;
+ size_t len;
+ size_t cap;
+} fm_buf;
+
+static inline void fm_buf_init(fm_buf *b) {
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+static inline void fm_buf_free(fm_buf *b) {
+ free(b->data);
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+static inline void fm_buf_reset(fm_buf *b) {
+ b->len = 0;
+}
+
+static inline void fm_buf_reserve(fm_buf *b, size_t extra) {
+ if (b->len + extra + 1 > b->cap) {
+ size_t cap = b->cap ? b->cap : 64;
+ while (cap < b->len + extra + 1)
+ cap *= 2;
+ b->data = (char *)fm_xrealloc(b->data, cap);
+ b->cap = cap;
+ }
+}
+
+static inline void fm_buf_putn(fm_buf *b, const char *s, size_t n) {
+ fm_buf_reserve(b, n);
+ memcpy(b->data + b->len, s, n);
+ b->len += n;
+ b->data[b->len] = '\0';
+}
+
+static inline void fm_buf_put(fm_buf *b, const char *s) {
+ if (s)
+ fm_buf_putn(b, s, strlen(s));
+}
+
+static inline void fm_buf_push(fm_buf *b, char c) {
+ fm_buf_reserve(b, 1);
+ b->data[b->len++] = c;
+ b->data[b->len] = '\0';
+}
+
+static inline int fm_buf_fmt(fm_buf *b, const char *fmt, ...) {
+ va_list ap, ap2;
+ if (!fmt)
+ return 0;
+ va_start(ap, fmt);
+ va_copy(ap2, ap);
+ int n = vsnprintf(NULL, 0, fmt, ap2);
+ va_end(ap2);
+ if (n < 0) {
+ va_end(ap);
+ return -1;
+ }
+ fm_buf_reserve(b, (size_t)n);
+ vsnprintf(b->data + b->len, (size_t)n + 1, fmt, ap);
+ va_end(ap);
+ b->len += (size_t)n;
+ return n;
+}
+
+static inline char *fm_buf_steal(fm_buf *b) {
+ if (!b->data)
+ b->data = (char *)fm_xmalloc(1);
+ b->data[b->len] = '\0';
+ char *s = b->data;
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+ return s;
+}
+
+/* ------------------------------------------------------------------ */
+/* String list */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ char **items;
+ size_t len;
+ size_t cap;
+} fm_strlist;
+
+static inline void fm_sl_init(fm_strlist *l) {
+ l->items = NULL;
+ l->len = 0;
+ l->cap = 0;
+}
+
+static inline void fm_sl_free(fm_strlist *l) {
+ for (size_t i = 0; i < l->len; i++)
+ free(l->items[i]);
+ free(l->items);
+ fm_sl_init(l);
+}
+
+static inline void fm_sl_push(fm_strlist *l, char *owned) {
+ if (l->len + 1 > l->cap) {
+ l->cap = l->cap ? l->cap * 2 : 8;
+ l->items = (char **)fm_xrealloc(l->items, l->cap * sizeof(char *));
+ }
+ l->items[l->len++] = owned;
+}
+
+static inline void fm_sl_push_copy(fm_strlist *l, const char *s) {
+ fm_sl_push(l, fm_strdup(s));
+}
+
+static inline void fm_sl_pop(fm_strlist *l) {
+ if (!l->len)
+ return;
+ free(l->items[--l->len]);
+}
+
+/* ------------------------------------------------------------------ */
+/* Embedded JSON values */
+/* Object, array, string, number, bool, null. Parse, serialize, */
+/* lookup, shallow-merge. */
+/* ------------------------------------------------------------------ */
+
+enum {
+ FMJ_OBJECT,
+ FMJ_ARRAY,
+ FMJ_STRING,
+ FMJ_NUMBER,
+ FMJ_BOOL,
+ FMJ_NULL,
+};
+
+typedef struct fm_json {
+ int type;
+ char *key; /* OBJECT member key (owned), else NULL */
+ char *str; /* FMJ_STRING value (owned), else NULL */
+ double num; /* FMJ_NUMBER */
+ int is_int; /* FMJ_NUMBER was an integer literal in range */
+ long long i; /* FMJ_NUMBER integral value */
+ int b; /* FMJ_BOOL */
+ struct fm_json **items; /* OBJECT/ARRAY members (owned) */
+ size_t count;
+} fm_json;
+
+static inline fm_json *fm_json_new(int type) {
+ fm_json *v = (fm_json *)fm_xmalloc(sizeof *v);
+ memset(v, 0, sizeof *v);
+ v->type = type;
+ return v;
+}
+
+static inline void fm_json_free(fm_json *v) {
+ if (!v)
+ return;
+ for (size_t i = 0; i < v->count; i++)
+ fm_json_free(v->items[i]);
+ free(v->items);
+ free(v->key);
+ free(v->str);
+ free(v);
+}
+
+static inline const fm_json *fm_json_get(const fm_json *o, const char *key) {
+ if (!o || o->type != FMJ_OBJECT || !key)
+ return NULL;
+ for (size_t i = 0; i < o->count; i++)
+ if (o->items[i] && o->items[i]->key &&
+ strcmp(o->items[i]->key, key) == 0)
+ return o->items[i];
+ return NULL;
+}
+
+/* Max nesting depth for the recursive-descent JSON parser; bounds stack
+ * growth so a pathologically deep input (e.g. 100k nested arrays) is
+ * rejected with an error instead of overflowing the C stack. */
+#define FM_JSON_MAX_DEPTH 256
+
+typedef struct {
+ const char *p;
+ const char *end;
+ int err;
+ size_t depth;
+} fm_jp;
+
+static inline void fm_jskip(fm_jp *s) {
+ while (s->p < s->end && (*s->p == ' ' || *s->p == '\t' || *s->p == '\n' ||
+ *s->p == '\r'))
+ s->p++;
+}
+
+/* String at s->p (opening quote). Returns owned unescaped text. */
+static inline char *fm_jstring_raw(fm_jp *s) {
+ if (s->p >= s->end || *s->p != '"') {
+ s->err = 1;
+ return NULL;
+ }
+ s->p++;
+ fm_buf b;
+ fm_buf_init(&b);
+ while (s->p < s->end && *s->p != '"') {
+ char c = *s->p++;
+ if (c == '\\' && s->p < s->end) {
+ char e = *s->p++;
+ switch (e) {
+ case '"':
+ fm_buf_push(&b, '"');
+ break;
+ case '\\':
+ fm_buf_push(&b, '\\');
+ break;
+ case '/':
+ fm_buf_push(&b, '/');
+ break;
+ case 'b':
+ fm_buf_push(&b, '\b');
+ break;
+ case 'f':
+ fm_buf_push(&b, '\f');
+ break;
+ case 'n':
+ fm_buf_push(&b, '\n');
+ break;
+ case 'r':
+ fm_buf_push(&b, '\r');
+ break;
+ case 't':
+ fm_buf_push(&b, '\t');
+ break;
+ case 'u': {
+ if (s->end - s->p < 4)
+ goto fail;
+ unsigned h = 0;
+ bool ok = true;
+ for (int k = 0; k < 4; k++) {
+ char hc = s->p[k];
+ int d;
+ if (hc >= '0' && hc <= '9')
+ d = hc - '0';
+ else if (hc >= 'a' && hc <= 'f')
+ d = hc - 'a' + 10;
+ else if (hc >= 'A' && hc <= 'F')
+ d = hc - 'A' + 10;
+ else {
+ ok = false;
+ break;
+ }
+ h = h * 16 + (unsigned)d;
+ }
+ if (!ok)
+ goto fail;
+ s->p += 4;
+ /* Join a UTF-16 surrogate pair (high \uD800-\uDBFF followed by
+ * low \uDC00-\uDFFF) into a single codepoint so astral chars
+ * (emoji, CJK ext-B, …) emit proper 4-byte UTF-8 rather than
+ * two CESU-8 3-byte sequences. Lone surrogates fall through to
+ * the 3-byte (lenient) encoding below. */
+ unsigned cp = h;
+ if (h >= 0xD800u && h <= 0xDBFFu &&
+ s->end - s->p >= 6 && s->p[0] == '\\' && s->p[1] == 'u') {
+ unsigned lo = 0;
+ bool lok = true;
+ for (int k = 0; k < 4; k++) {
+ char hc = s->p[2 + k];
+ int d;
+ if (hc >= '0' && hc <= '9')
+ d = hc - '0';
+ else if (hc >= 'a' && hc <= 'f')
+ d = hc - 'a' + 10;
+ else if (hc >= 'A' && hc <= 'F')
+ d = hc - 'A' + 10;
+ else {
+ lok = false;
+ break;
+ }
+ lo = lo * 16 + (unsigned)d;
+ }
+ if (lok && lo >= 0xDC00u && lo <= 0xDFFFu) {
+ cp = 0x10000u + ((h - 0xD800u) << 10) + (lo - 0xDC00u);
+ s->p += 6;
+ }
+ }
+ if (cp < 0x80) {
+ fm_buf_push(&b, (char)cp);
+ } else if (cp < 0x800) {
+ fm_buf_push(&b, (char)(0xC0 | (cp >> 6)));
+ fm_buf_push(&b, (char)(0x80 | (cp & 0x3F)));
+ } else if (cp < 0x10000) {
+ fm_buf_push(&b, (char)(0xE0 | (cp >> 12)));
+ fm_buf_push(&b, (char)(0x80 | ((cp >> 6) & 0x3F)));
+ fm_buf_push(&b, (char)(0x80 | (cp & 0x3F)));
+ } else {
+ fm_buf_push(&b, (char)(0xF0 | (cp >> 18)));
+ fm_buf_push(&b, (char)(0x80 | ((cp >> 12) & 0x3F)));
+ fm_buf_push(&b, (char)(0x80 | ((cp >> 6) & 0x3F)));
+ fm_buf_push(&b, (char)(0x80 | (cp & 0x3F)));
+ }
+ break;
+ }
+ default:
+ goto fail;
+ }
+ } else if (c < 0x20) {
+ goto fail; /* raw control char: invalid JSON */
+ } else {
+ fm_buf_push(&b, c);
+ }
+ }
+ if (s->p >= s->end || *s->p != '"')
+ goto fail;
+ s->p++;
+ return fm_buf_steal(&b);
+fail:
+ s->err = 1;
+ fm_buf_free(&b);
+ return NULL;
+}
+
+static inline fm_json *fm_jvalue(fm_jp *s);
+
+static inline fm_json *fm_jobject(fm_jp *s) {
+ if (s->depth >= FM_JSON_MAX_DEPTH) {
+ s->err = 1;
+ return NULL;
+ }
+ s->depth++;
+ s->p++; /* { */
+ fm_json *o = fm_json_new(FMJ_OBJECT);
+ for (;;) {
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == '}') {
+ s->p++;
+ s->depth--;
+ return o;
+ }
+ char *key = fm_jstring_raw(s);
+ if (!key) {
+ fm_json_free(o);
+ return NULL;
+ }
+ fm_jskip(s);
+ if (s->p >= s->end || *s->p != ':') {
+ free(key);
+ fm_json_free(o);
+ s->err = 1;
+ return NULL;
+ }
+ s->p++;
+ fm_json *v = fm_jvalue(s);
+ if (!v) {
+ free(key);
+ fm_json_free(o);
+ return NULL;
+ }
+ v->key = key;
+ o->items =
+ (fm_json **)fm_xrealloc(o->items, (o->count + 1) * sizeof(fm_json *));
+ o->items[o->count++] = v;
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == ',') {
+ s->p++;
+ continue;
+ }
+ if (s->p < s->end && *s->p == '}') {
+ s->p++;
+ s->depth--;
+ return o;
+ }
+ fm_json_free(o);
+ s->err = 1;
+ return NULL;
+ }
+}
+
+static inline fm_json *fm_jarray(fm_jp *s) {
+ if (s->depth >= FM_JSON_MAX_DEPTH) {
+ s->err = 1;
+ return NULL;
+ }
+ s->depth++;
+ s->p++; /* [ */
+ fm_json *a = fm_json_new(FMJ_ARRAY);
+ for (;;) {
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == ']') {
+ s->p++;
+ s->depth--;
+ return a;
+ }
+ fm_json *v = fm_jvalue(s);
+ if (!v) {
+ fm_json_free(a);
+ return NULL;
+ }
+ a->items =
+ (fm_json **)fm_xrealloc(a->items, (a->count + 1) * sizeof(fm_json *));
+ a->items[a->count++] = v;
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == ',') {
+ s->p++;
+ continue;
+ }
+ if (s->p < s->end && *s->p == ']') {
+ s->p++;
+ s->depth--;
+ return a;
+ }
+ fm_json_free(a);
+ s->err = 1;
+ return NULL;
+ }
+}
+
+static inline fm_json *fm_jvalue(fm_jp *s) {
+ fm_jskip(s);
+ if (s->p >= s->end) {
+ s->err = 1;
+ return NULL;
+ }
+ char c = *s->p;
+ if (c == '{')
+ return fm_jobject(s);
+ if (c == '[')
+ return fm_jarray(s);
+ if (c == '"') {
+ char *t = fm_jstring_raw(s);
+ if (!t)
+ return NULL;
+ fm_json *v = fm_json_new(FMJ_STRING);
+ v->str = t;
+ return v;
+ }
+ if (s->end - s->p >= 4 && strncmp(s->p, "true", 4) == 0) {
+ s->p += 4;
+ fm_json *v = fm_json_new(FMJ_BOOL);
+ v->b = 1;
+ return v;
+ }
+ if (s->end - s->p >= 5 && strncmp(s->p, "false", 5) == 0) {
+ s->p += 5;
+ fm_json *v = fm_json_new(FMJ_BOOL);
+ v->b = 0;
+ return v;
+ }
+ if (s->end - s->p >= 4 && strncmp(s->p, "null", 4) == 0) {
+ s->p += 4;
+ return fm_json_new(FMJ_NULL);
+ }
+ if (c == '-' || (c >= '0' && c <= '9')) {
+ const char *start = s->p;
+ if (c == '-')
+ s->p++;
+ if (s->p >= s->end || *s->p < '0' || *s->p > '9') {
+ s->err = 1;
+ return NULL;
+ }
+ if (*s->p == '0' && s->p + 1 < s->end && s->p[1] >= '0' &&
+ s->p[1] <= '9') {
+ s->err = 1; /* leading zero */
+ return NULL;
+ }
+ while (s->p < s->end && *s->p >= '0' && *s->p <= '9')
+ s->p++;
+ int is_int = 1;
+ if (s->p < s->end && *s->p == '.') {
+ is_int = 0;
+ s->p++;
+ if (s->p >= s->end || *s->p < '0' || *s->p > '9') {
+ s->err = 1;
+ return NULL;
+ }
+ while (s->p < s->end && *s->p >= '0' && *s->p <= '9')
+ s->p++;
+ }
+ if (s->p < s->end && (*s->p == 'e' || *s->p == 'E')) {
+ is_int = 0;
+ s->p++;
+ if (s->p < s->end && (*s->p == '+' || *s->p == '-'))
+ s->p++;
+ if (s->p >= s->end || *s->p < '0' || *s->p > '9') {
+ s->err = 1;
+ return NULL;
+ }
+ while (s->p < s->end && *s->p >= '0' && *s->p <= '9')
+ s->p++;
+ }
+ char *endp = NULL;
+ errno = 0;
+ double d = strtod(start, &endp);
+ if (errno != 0 || endp == start) {
+ s->err = 1;
+ return NULL;
+ }
+ fm_json *v = fm_json_new(FMJ_NUMBER);
+ v->num = d;
+ if (is_int && d >= -9.0e18 && d <= 9.0e18) {
+ v->is_int = 1;
+ v->i = (long long)d;
+ }
+ return v;
+ }
+ s->err = 1;
+ return NULL;
+}
+
+static inline fm_json *fm_json_parse(const char *text) {
+ if (!text)
+ return NULL;
+ fm_jp s = {text, text + strlen(text), 0, 0};
+ fm_json *v = fm_jvalue(&s);
+ if (!v)
+ return NULL;
+ fm_jskip(&s);
+ if (s.p != s.end) { /* trailing garbage */
+ fm_json_free(v);
+ return NULL;
+ }
+ return v;
+}
+
+static inline void fm_jstring_out(fm_buf *b, const char *s) {
+ fm_buf_push(b, '"');
+ for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
+ unsigned char c = *p;
+ switch (c) {
+ case '"':
+ fm_buf_put(b, "\\\"");
+ break;
+ case '\\':
+ fm_buf_put(b, "\\\\");
+ break;
+ case '\b':
+ fm_buf_put(b, "\\b");
+ break;
+ case '\f':
+ fm_buf_put(b, "\\f");
+ break;
+ case '\n':
+ fm_buf_put(b, "\\n");
+ break;
+ case '\r':
+ fm_buf_put(b, "\\r");
+ break;
+ case '\t':
+ fm_buf_put(b, "\\t");
+ break;
+ default:
+ if (c < 0x20)
+ fm_buf_fmt(b, "\\u%04x", c);
+ else
+ fm_buf_push(b, (char)c);
+ }
+ }
+ fm_buf_push(b, '"');
+}
+
+static inline void fm_json_out(const fm_json *v, fm_buf *b) {
+ switch (v->type) {
+ case FMJ_NULL:
+ fm_buf_put(b, "null");
+ break;
+ case FMJ_BOOL:
+ fm_buf_put(b, v->b ? "true" : "false");
+ break;
+ case FMJ_STRING:
+ fm_jstring_out(b, v->str ? v->str : "");
+ break;
+ case FMJ_NUMBER:
+ if (v->is_int)
+ fm_buf_fmt(b, "%lld", v->i);
+ else
+ fm_buf_fmt(b, "%.15g", v->num);
+ break;
+ case FMJ_ARRAY:
+ fm_buf_push(b, '[');
+ for (size_t i = 0; i < v->count; i++) {
+ if (i)
+ fm_buf_put(b, ",");
+ fm_json_out(v->items[i], b);
+ }
+ fm_buf_push(b, ']');
+ break;
+ case FMJ_OBJECT:
+ fm_buf_push(b, '{');
+ for (size_t i = 0; i < v->count; i++) {
+ if (i)
+ fm_buf_put(b, ",");
+ fm_jstring_out(b, v->items[i]->key ? v->items[i]->key : "");
+ fm_buf_put(b, ":");
+ fm_json_out(v->items[i], b);
+ }
+ fm_buf_push(b, '}');
+ break;
+ }
+}
+
+static inline char *fm_json_serialize(const fm_json *v) {
+ fm_buf b;
+ fm_buf_init(&b);
+ if (v)
+ fm_json_out(v, &b);
+ return fm_buf_steal(&b);
+}
+
+/* Shallow merge of two objects: union of keys, `over` wins per key.
+ * Takes ownership of both inputs (either may be NULL or non-object, in
+ * which case it is dropped). Returns a new object; free with fm_json_free.
+ * Member order: over's keys first, then base-only keys. */
+static inline fm_json *fm_json_merge_owned(const fm_json *base,
+ const fm_json *over) {
+ fm_json *B = (base && base->type == FMJ_OBJECT) ? (fm_json *)base : NULL;
+ fm_json *O = (over && over->type == FMJ_OBJECT) ? (fm_json *)over : NULL;
+ if (base && base->type != FMJ_OBJECT)
+ fm_json_free((fm_json *)base);
+ if (over && over->type != FMJ_OBJECT)
+ fm_json_free((fm_json *)over);
+ fm_json *r = fm_json_new(FMJ_OBJECT);
+ if (O) {
+ for (size_t i = 0; i < O->count; i++)
+ if (O->items[i]) {
+ r->items =
+ (fm_json **)fm_xrealloc(r->items,
+ (r->count + 1) * sizeof(fm_json *));
+ r->items[r->count++] = O->items[i];
+ }
+ }
+ if (B) {
+ for (size_t i = 0; i < B->count; i++) {
+ fm_json *m = B->items[i];
+ if (!m)
+ continue;
+ if (O && fm_json_get(O, m->key))
+ fm_json_free(m); /* superseded */
+ else {
+ r->items =
+ (fm_json **)fm_xrealloc(r->items,
+ (r->count + 1) * sizeof(fm_json *));
+ r->items[r->count++] = m;
+ }
+ }
+ }
+ if (B) {
+ free(B->items);
+ free(B->key);
+ free(B->str);
+ free(B);
+ }
+ if (O) {
+ free(O->items);
+ free(O->key);
+ free(O->str);
+ free(O);
+ }
+ return r;
+}
+
+/* ------------------------------------------------------------------ */
+/* Frontmatter */
+/* ------------------------------------------------------------------ */
+
+/* Frontmatter value */
+typedef enum {
+ FM_STR,
+ FM_LIST,
+ FM_BOOL,
+ FM_INT,
+} fm_kind;
+
+typedef struct {
+ fm_kind kind;
+ char *s;
+ char **items;
+ size_t item_count;
+ bool b;
+ long long i;
+} fm_value;
+
+static inline void fm_value_free(fm_value *v) {
+ free(v->s);
+ for (size_t i = 0; i < v->item_count; i++)
+ free(v->items[i]);
+ free(v->items);
+ memset(v, 0, sizeof *v);
+}
+
+/* List-valued field as items: JSON array as-is, CSV split on commas,
+ * single string as one item. */
+static inline void fm_list_items(const fm_value *v, fm_strlist *out) {
+ fm_sl_init(out);
+ if (!v)
+ return;
+ if (v->kind == FM_LIST) {
+ for (size_t i = 0; i < v->item_count; i++)
+ fm_sl_push_copy(out, v->items[i]);
+ return;
+ }
+ if (v->kind != FM_STR || !v->s || !strchr(v->s, ',')) {
+ if (v->s) {
+ size_t a = 0, n = strlen(v->s);
+ while (a < n && isspace((unsigned char)v->s[a]))
+ a++;
+ while (n > a && isspace((unsigned char)v->s[n - 1]))
+ n--;
+ if (n > a)
+ fm_sl_push(out, fm_strndup(v->s + a, n - a));
+ }
+ return;
+ }
+ const char *s = v->s;
+ size_t n = strlen(s);
+ size_t start = 0;
+ for (;;) {
+ while (start < n && isspace((unsigned char)s[start]))
+ start++;
+ if (start >= n)
+ break;
+ const char *comma = (const char *)memchr(s + start, ',', n - start);
+ size_t seg_end = comma ? (size_t)(comma - s) : n;
+ size_t a = start, b = seg_end;
+ while (b > a && isspace((unsigned char)s[b - 1]))
+ b--;
+ if (b > a)
+ fm_sl_push(out, fm_strndup(s + a, b - a));
+ if (!comma)
+ break;
+ start = seg_end + 1;
+ }
+}
+
+/* A frontmatter entry */
+typedef struct {
+ char *key;
+ fm_value val;
+} fm_entry;
+
+/* A markdown document with optional `---` frontmatter */
+typedef struct {
+ fm_entry *entries;
+ size_t count;
+ size_t cap;
+ char *body;
+} doc;
+
+static inline const fm_value *doc_get(const doc *d, const char *key) {
+ for (size_t i = 0; i < d->count; i++)
+ if (strcmp(d->entries[i].key, key) == 0)
+ return &d->entries[i].val;
+ return NULL;
+}
+
+static inline void doc_free(doc *d) {
+ for (size_t i = 0; i < d->count; i++) {
+ free(d->entries[i].key);
+ fm_value_free(&d->entries[i].val);
+ }
+ free(d->entries);
+ free(d->body);
+ memset(d, 0, sizeof *d);
+}
+
+static inline void fm_parse_value(const char *raw, fm_value *v) {
+ memset(v, 0, sizeof *v);
+ if (raw[0] == '[') {
+ fm_json *arr = fm_json_parse(raw);
+ if (arr && arr->type == FMJ_ARRAY) {
+ v->kind = FM_LIST;
+ v->items = (char **)fm_xmalloc(arr->count * sizeof(char *));
+ size_t i = 0;
+ for (size_t k = 0; k < arr->count; k++) {
+ const fm_json *it = arr->items[k];
+ if (it && it->type == FMJ_STRING)
+ v->items[i++] = fm_strdup(it->str);
+ else
+ v->items[i++] = it ? fm_json_serialize(it)
+ : fm_strdup("");
+ }
+ v->item_count = arr->count;
+ fm_json_free(arr);
+ return;
+ }
+ fm_json_free(arr);
+ v->kind = FM_STR;
+ v->s = fm_strdup(raw);
+ return;
+ }
+ if (strcmp(raw, "true") == 0) {
+ v->kind = FM_BOOL;
+ v->b = true;
+ return;
+ }
+ if (strcmp(raw, "false") == 0) {
+ v->kind = FM_BOOL;
+ v->b = false;
+ return;
+ }
+ size_t len = strlen(raw);
+ if (len >= 2 &&
+ ((raw[0] == '"' && raw[len - 1] == '"') ||
+ (raw[0] == '\'' && raw[len - 1] == '\''))) {
+ v->kind = FM_STR;
+ v->s = fm_strndup(raw + 1, len - 2);
+ return;
+ }
+ /* integer? */
+ char *end = NULL;
+ errno = 0;
+ long long n = strtoll(raw, &end, 10);
+ if (errno == 0 && end && *end == '\0' && raw[0] != '\0') {
+ v->kind = FM_INT;
+ v->i = n;
+ return;
+ }
+ v->kind = FM_STR;
+ v->s = fm_strdup(raw);
+}
+
+/* Split text into lines (no trailing empty). Returns line count. */
+static inline char **fm_split_lines(const char *text, size_t *out_count) {
+ size_t cap = 16, n = 0;
+ char **lines = (char **)fm_xmalloc(cap * sizeof(char *));
+ const char *p = text;
+ while (*p) {
+ const char *nl = (const char *)strchr(p, '\n');
+ size_t len = nl ? (size_t)(nl - p) : strlen(p);
+ if (n + 1 > cap) {
+ cap *= 2;
+ lines = (char **)fm_xrealloc(lines, cap * sizeof(char *));
+ }
+ lines[n++] = fm_strndup(p, len);
+ if (!nl)
+ break;
+ p = nl + 1;
+ }
+ *out_count = n;
+ return lines;
+}
+
+static inline void parse_frontmatter(const char *text, doc *out) {
+ memset(out, 0, sizeof *out);
+ size_t nlines = 0;
+ char **lines = fm_split_lines(text, &nlines);
+
+ char *body = fm_strdup(text); /* default: whole text */
+ if (nlines == 0) {
+ /* keep as-is */
+ } else if (strcmp(lines[0], "---") != 0) {
+ /* trim end */
+ size_t blen = strlen(body);
+ while (blen && (body[blen - 1] == '\n' || body[blen - 1] == '\r' ||
+ body[blen - 1] == ' '))
+ body[--blen] = '\0';
+ } else {
+ size_t close = 0;
+ bool found = false;
+ for (size_t i = 1; i < nlines; i++) {
+ if (strcmp(lines[i], "---") == 0) {
+ close = i - 1; /* index within lines[1..] */
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ /* whole text is body, trimmed */
+ size_t blen = strlen(body);
+ while (blen && (body[blen - 1] == '\n' || body[blen - 1] == '\r' ||
+ body[blen - 1] == ' '))
+ body[--blen] = '\0';
+ } else {
+ for (size_t i = 1; i <= close; i++) {
+ const char *line = lines[i];
+ /* trim */
+ size_t start = 0, len = strlen(line);
+ while (start < len && isspace((unsigned char)line[start]))
+ start++;
+ while (len > start &&
+ isspace((unsigned char)line[len - 1]))
+ len--;
+ if (len == 0 || line[start] == '#')
+ continue;
+ const char *colon =
+ (const char *)memchr(line + start, ':', len - start);
+ if (!colon)
+ continue;
+ size_t klen = (size_t)(colon - (line + start));
+ if (klen == 0)
+ continue;
+ char *key = fm_strndup(line + start, klen);
+ const char *vraw = colon + 1;
+ /* trim value */
+ size_t vlen = strlen(vraw);
+ size_t vstart = 0;
+ while (vstart < vlen &&
+ isspace((unsigned char)vraw[vstart]))
+ vstart++;
+ while (vlen > vstart &&
+ isspace((unsigned char)vraw[vlen - 1]))
+ vlen--;
+ fm_value val;
+ char *vtmp = fm_strndup(vraw + vstart, vlen - vstart);
+ fm_parse_value(vtmp, &val);
+ free(vtmp);
+ if (out->count + 1 > out->cap) {
+ out->cap = out->cap ? out->cap * 2 : 8;
+ out->entries =
+ (fm_entry *)fm_xrealloc(out->entries,
+ out->cap *
+ sizeof(out->entries[0]));
+ }
+ out->entries[out->count].key = key;
+ out->entries[out->count].val = val;
+ out->count++;
+ }
+ /* body = lines[2+close..] joined by \n, trimmed end */
+ fm_buf b;
+ fm_buf_init(&b);
+ for (size_t i = 2 + close; i < nlines; i++) {
+ if (i > 2 + close)
+ fm_buf_push(&b, '\n');
+ fm_buf_put(&b, lines[i]);
+ }
+ char *nb = fm_buf_steal(&b);
+ size_t blen = strlen(nb);
+ while (blen && (nb[blen - 1] == '\n' || nb[blen - 1] == '\r' ||
+ nb[blen - 1] == ' '))
+ nb[--blen] = '\0';
+ free(body);
+ body = nb;
+ }
+ }
+ for (size_t i = 0; i < nlines; i++)
+ free(lines[i]);
+ free(lines);
+ out->body = body;
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* FRONTMATTER_H */
diff --git a/include/type.h b/include/type.h
new file mode 100644
index 0000000..1106832
--- /dev/null
+++ b/include/type.h
@@ -0,0 +1,24 @@
+// type.h - Common type aliases
+//
+// Used across header libraries to provide consistent, concise type names
+// for fixed-width integers and floating point types.
+
+#ifndef TYPE_H
+#define TYPE_H
+
+#include <stdint.h>
+
+typedef double f64;
+typedef float f32;
+
+typedef int8_t i8;
+typedef int16_t i16;
+typedef int32_t i32;
+typedef int64_t i64;
+
+typedef uint8_t u8;
+typedef uint16_t u16;
+typedef uint32_t u32;
+typedef uint64_t u64;
+
+#endif
diff --git a/src/agent.cpp b/src/agent.cpp
new file mode 100644
index 0000000..d1357b7
--- /dev/null
+++ b/src/agent.cpp
@@ -0,0 +1,435 @@
+#include "agent.h"
+
+#include <curl/curl.h>
+#include <cmath>
+#include <limits>
+#include <stdlib.h>
+#include <string.h>
+
+static char *trim_slashes(char *s) {
+ size_t n = strlen(s);
+ while (n && s[n - 1] == '/')
+ s[--n] = '\0';
+ return s;
+}
+
+void provider_probe_free(provider_probe *p) {
+ free(p->detail);
+ p->detail = NULL;
+}
+
+const char *agent_default_base_url(provider_kind p) {
+ switch (p) {
+ case PROV_OPENAI:
+ return "https://api.openai.com/v1";
+ case PROV_ANTHROPIC:
+ return "https://api.anthropic.com";
+ case PROV_OLLAMA:
+ return "http://localhost:11434";
+ }
+ return "https://api.openai.com/v1";
+}
+
+char *agent_models_url(const config *cfg) {
+ char *base = xstrdup(cfg->base_url ? cfg->base_url
+ : agent_default_base_url(cfg->provider));
+ trim_slashes(base);
+ xbuf u;
+ xbuf_init(&u);
+ xbuf_put(&u, base);
+ switch (cfg->provider) {
+ case PROV_OPENAI:
+ xbuf_push(&u, '/');
+ xbuf_put(&u, "models");
+ break;
+ case PROV_ANTHROPIC: {
+ /* The canonical base is api.anthropic.com; the /v1 prefix belongs to
+ * the path. If the caller already included /v1 in base_url, don't
+ * duplicate it (avoids .../v1/v1/models). */
+ size_t blen = strlen(base);
+ if (blen >= 3 && memcmp(base + blen - 3, "/v1", 3) == 0)
+ xbuf_put(&u, "/models?limit=100");
+ else
+ xbuf_put(&u, "/v1/models?limit=100");
+ break;
+ }
+ case PROV_OLLAMA: {
+ xbuf_put(&u, "/api/show?name=");
+ char *enc = cfg->model ? curl_easy_escape(NULL, cfg->model, 0) : NULL;
+ xbuf_put(&u, enc ? enc : "");
+ free(enc);
+ break;
+ }
+ }
+ char *out = xbuf_steal(&u);
+ free(base);
+ return out;
+}
+
+void agent_auth(const config *cfg, char **header, char **value) {
+ if (header)
+ *header = NULL;
+ if (value)
+ *value = NULL;
+ switch (cfg->provider) {
+ case PROV_OPENAI:
+ if (cfg->api_key) {
+ if (header)
+ *header = xstrdup("Authorization");
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b, "Bearer %s", cfg->api_key);
+ if (value)
+ *value = xbuf_steal(&b);
+ else
+ xbuf_free(&b);
+ }
+ break;
+ case PROV_ANTHROPIC:
+ if (cfg->api_key) {
+ if (header)
+ *header = xstrdup("x-api-key");
+ if (value)
+ *value = xstrdup(cfg->api_key);
+ }
+ break;
+ case PROV_OLLAMA:
+ break;
+ }
+}
+
+int agent_http_get(const char *url, const char *auth_header,
+ const char *auth_value, long *status, char **body,
+ char **err) {
+ if (body)
+ *body = NULL;
+ if (err)
+ *err = NULL;
+ CURL *c = curl_easy_init();
+ if (!c) {
+ if (err)
+ *err = xstrdup("could not initialize curl");
+ return -1;
+ }
+ xbuf b;
+ xbuf_init(&b);
+ struct curl_slist *h = NULL;
+ if (auth_header && auth_value) {
+ xbuf hb;
+ xbuf_init(&hb);
+ xbuf_fmt(&hb, "%s: %s", auth_header, auth_value);
+ h = curl_slist_append(h, hb.data);
+ xbuf_free(&hb);
+ curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
+ }
+ curl_easy_setopt(c, CURLOPT_URL, url);
+ curl_easy_setopt(c, CURLOPT_TIMEOUT, 5L);
+ curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
+ const char *ca = llsh_ca_bundle();
+ if (ca)
+ curl_easy_setopt(c, CURLOPT_CAINFO, ca);
+ curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, xbuf_curl_write);
+ curl_easy_setopt(c, CURLOPT_WRITEDATA, &b);
+ CURLcode rc = curl_easy_perform(c);
+ curl_slist_free_all(h);
+ if (rc != CURLE_OK) {
+ char *e = xstrdup(curl_easy_strerror(rc));
+ free(b.data);
+ curl_easy_cleanup(c);
+ if (err)
+ *err = e;
+ return -1;
+ }
+ long code = 0;
+ curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &code);
+ if (status)
+ *status = code;
+ if (body)
+ *body = xbuf_steal(&b);
+ else
+ free(b.data);
+ curl_easy_cleanup(c);
+ return 0;
+}
+
+static bool str_eq(const cJSON *v, const char *s) {
+ return cJSON_IsString(v) && v->valuestring &&
+ strcmp(v->valuestring, s) == 0;
+}
+
+static unsigned long num_u64(const cJSON *v) {
+ if (cJSON_IsNumber(v) && std::isfinite(v->valuedouble) &&
+ v->valuedouble >= 0 && std::floor(v->valuedouble) == v->valuedouble &&
+ v->valuedouble < (double)std::numeric_limits<unsigned long>::max())
+ return (unsigned long)v->valuedouble;
+ return 0;
+}
+
+static bool openai_model_matches_entry(const cJSON *entry, const char *model) {
+ if (str_eq(cJSON_GetObjectItem(entry, "id"), model))
+ return true;
+ if (str_eq(cJSON_GetObjectItem(entry, "name"), model))
+ return true;
+ const cJSON *al = cJSON_GetObjectItem(entry, "aliases");
+ if (cJSON_IsArray(al)) {
+ const cJSON *x = NULL;
+ cJSON_ArrayForEach(x, al)
+ if (str_eq(x, model))
+ return true;
+ }
+ return false;
+}
+
+bool openai_model_present(const char *body, const char *model) {
+ cJSON *v = cJSON_Parse(body);
+ if (!v)
+ return false;
+ bool found = false;
+ cJSON *arr = cJSON_GetObjectItem(v, "data");
+ if (!arr)
+ arr = cJSON_GetObjectItem(v, "models");
+ if (cJSON_IsArray(arr)) {
+ const cJSON *m = NULL;
+ cJSON_ArrayForEach(m, arr)
+ if (openai_model_matches_entry(m, model)) {
+ found = true;
+ break;
+ }
+ }
+ cJSON_Delete(v);
+ return found;
+}
+
+bool anthropic_model_present(const char *body, const char *model) {
+ cJSON *v = cJSON_Parse(body);
+ if (!v)
+ return false;
+ bool found = false;
+ const cJSON *arr = cJSON_GetObjectItem(v, "data");
+ if (cJSON_IsArray(arr)) {
+ const cJSON *m = NULL;
+ cJSON_ArrayForEach(m, arr)
+ if (str_eq(cJSON_GetObjectItem(m, "id"), model)) {
+ found = true;
+ break;
+ }
+ }
+ cJSON_Delete(v);
+ return found;
+}
+
+unsigned long extract_context_limit(const cJSON *json, const config *cfg) {
+ if (!json)
+ return 0;
+ switch (cfg->provider) {
+ case PROV_OPENAI: {
+ unsigned long v = num_u64(cJSON_GetObjectItem(json, "context_length"));
+ if (v)
+ return v;
+ const cJSON *data = cJSON_GetObjectItem(json, "data");
+ if (!cJSON_IsArray(data))
+ return 0;
+ const cJSON *want = NULL;
+ const cJSON *m = NULL;
+ cJSON_ArrayForEach(m, data)
+ if (openai_model_matches_entry(m, cfg->model)) {
+ want = m;
+ break;
+ }
+ if (!want && data->child)
+ want = data->child;
+ if (!want)
+ return 0;
+ v = num_u64(cJSON_GetObjectItem(want, "context_length"));
+ if (v)
+ return v;
+ v = num_u64(cJSON_GetObjectItem(cJSON_GetObjectItem(want, "meta"),
+ "n_ctx"));
+ if (v)
+ return v;
+ return num_u64(cJSON_GetObjectItem(want, "context_window"));
+ }
+ case PROV_OLLAMA: {
+ unsigned long v = num_u64(cJSON_GetObjectItem(
+ cJSON_GetObjectItem(json, "model_details"), "context_length"));
+ if (v)
+ return v;
+ const cJSON *mf = cJSON_GetObjectItem(json, "modelfiles");
+ if (cJSON_IsArray(mf) && mf->child)
+ v = num_u64(cJSON_GetObjectItem(
+ cJSON_GetObjectItem(mf->child, "parameters"),
+ "context_length"));
+ return v;
+ }
+ case PROV_ANTHROPIC: {
+ const cJSON *data = cJSON_GetObjectItem(json, "data");
+ if (!cJSON_IsArray(data) || !data->child)
+ return 0;
+ const cJSON *want = NULL;
+ const cJSON *m = NULL;
+ cJSON_ArrayForEach(m, data)
+ if (str_eq(cJSON_GetObjectItem(m, "id"), cfg->model)) {
+ want = m;
+ break;
+ }
+ if (!want)
+ want = data->child;
+ return num_u64(cJSON_GetObjectItem(want, "max_tokens"));
+ }
+ }
+ return 0;
+}
+
+unsigned long agent_fetch_context_limit(const config *cfg) {
+ char *url = agent_models_url(cfg);
+ char *h = NULL, *v = NULL;
+ agent_auth(cfg, &h, &v);
+ long status = 0;
+ char *body = NULL;
+ char *err = NULL;
+ int rc = agent_http_get(url, h, v, &status, &body, &err);
+ free(h);
+ free(v);
+ free(url);
+ free(err);
+ if (rc != 0) {
+ free(body);
+ return 0;
+ }
+ unsigned long limit = 0;
+ /* No status gate (parity): a non-2xx body just has to parse and carry
+ * the field. */
+ cJSON *j = cJSON_Parse(body);
+ if (j) {
+ limit = extract_context_limit(j, cfg);
+ cJSON_Delete(j);
+ }
+ free(body);
+ (void)status;
+ return limit;
+}
+
+static const char *status_phrase(long code) {
+ switch (code) {
+ case 200: return "200 OK";
+ case 201: return "201 Created";
+ case 204: return "204 No Content";
+ case 400: return "400 Bad Request";
+ case 401: return "401 Unauthorized";
+ case 403: return "403 Forbidden";
+ case 404: return "404 Not Found";
+ case 405: return "405 Method Not Allowed";
+ case 408: return "408 Request Timeout";
+ case 429: return "429 Too Many Requests";
+ case 500: return "500 Internal Server Error";
+ case 502: return "502 Bad Gateway";
+ case 503: return "503 Service Unavailable";
+ case 504: return "504 Gateway Timeout";
+ default: {
+ static char buf[32];
+ snprintf(buf, sizeof buf, "%ld", code);
+ return buf;
+ }
+ }
+}
+
+provider_probe agent_probe_provider(const config *cfg) {
+ provider_probe p = {false, NULL};
+ char *base = xstrdup(cfg->base_url ? cfg->base_url
+ : agent_default_base_url(cfg->provider));
+ trim_slashes(base);
+ char *url = agent_models_url(cfg);
+ char *h = NULL, *v = NULL;
+ agent_auth(cfg, &h, &v);
+ long status = 0;
+ char *body = NULL;
+ char *err = NULL;
+ if (agent_http_get(url, h, v, &status, &body, &err) != 0) {
+ xbuf fb;
+ xbuf_init(&fb);
+ xbuf_fmt(&fb, "could not reach %s (connection: %s); is the endpoint "
+ "up and reachable?",
+ base, err ? err : "unknown");
+ free(err);
+ free(h);
+ free(v);
+ free(url);
+ free(base);
+ free(body);
+ p.detail = xbuf_steal(&fb);
+ return p;
+ }
+ free(h);
+ free(v);
+ free(url);
+ bool present = false;
+ switch (cfg->provider) {
+ case PROV_OPENAI:
+ present = openai_model_present(body ? body : "", cfg->model);
+ break;
+ case PROV_ANTHROPIC:
+ present = anthropic_model_present(body ? body : "", cfg->model);
+ break;
+ case PROV_OLLAMA:
+ present = (status >= 200 && status < 300);
+ break;
+ }
+ bool success = status >= 200 && status < 300;
+ bool client_err = status >= 400 && status < 500;
+ xbuf fb;
+ xbuf_init(&fb);
+ switch (cfg->provider) {
+ case PROV_OPENAI:
+ if (success) {
+ if (present) {
+ p.ok = true;
+ xbuf_fmt(&fb, "model `%s` found in the provider list at %s",
+ cfg->model, base);
+ } else {
+ xbuf_fmt(&fb, "model `%s` not in the provider's model list "
+ "at %s",
+ cfg->model, base);
+ }
+ } else if (client_err) {
+ xbuf_fmt(&fb, "provider returned %s for %s/models (check api_key)",
+ status_phrase(status), base);
+ } else {
+ xbuf_fmt(&fb, "provider returned %s for %s/models",
+ status_phrase(status), base);
+ }
+ break;
+ case PROV_ANTHROPIC:
+ if (success) {
+ if (present) {
+ p.ok = true;
+ xbuf_fmt(&fb, "model `%s` found in the provider list",
+ cfg->model);
+ } else {
+ xbuf_fmt(&fb, "model `%s` not in the provider's model list "
+ "at %s",
+ cfg->model, base);
+ }
+ } else if (client_err) {
+ xbuf_fmt(&fb, "provider returned %s for %s/v1/models (check "
+ "api_key)",
+ status_phrase(status), base);
+ } else {
+ xbuf_fmt(&fb, "provider returned %s for %s/v1/models",
+ status_phrase(status), base);
+ }
+ break;
+ case PROV_OLLAMA:
+ if (success) {
+ p.ok = true;
+ xbuf_fmt(&fb, "model `%s` found via Ollama", cfg->model);
+ } else {
+ xbuf_fmt(&fb, "Ollama returned %s at %s (model `%s`)",
+ status_phrase(status), base, cfg->model);
+ }
+ break;
+ }
+ free(body);
+ p.detail = xbuf_steal(&fb);
+ free(base);
+ return p;
+}
diff --git a/src/agent.h b/src/agent.h
new file mode 100644
index 0000000..a18f55e
--- /dev/null
+++ b/src/agent.h
@@ -0,0 +1,41 @@
+#ifndef LLSH_AGENT_H
+#define LLSH_AGENT_H
+
+#include "config.h"
+
+#include <cjson/cJSON.h>
+
+/* Mirrors agent.rs: provider probe + context-limit fetch (libcurl). */
+
+typedef struct {
+ bool ok;
+ char *detail; /* owned; release with provider_probe_free */
+} provider_probe;
+
+void provider_probe_free(provider_probe *p);
+
+const char *agent_default_base_url(provider_kind p);
+
+/* Model-list endpoint for the probe/fetch. Caller frees. */
+char *agent_models_url(const config *cfg);
+/* Auth header for the model-list endpoint. Caller frees each non-NULL.
+ * Ollama sends no auth: both come back NULL. */
+void agent_auth(const config *cfg, char **header, char **value);
+
+/* GET `url` with a 5s timeout. 0 = HTTP response received (*status, *body
+ * owned); -1 = transport failure (*err owned, body NULL). */
+int agent_http_get(const char *url, const char *auth_header,
+ const char *auth_value, long *status, char **body,
+ char **err);
+
+/* Pull the window size out of a provider's model-info response. 0 = absent. */
+unsigned long extract_context_limit(const cJSON *json, const config *cfg);
+
+bool openai_model_present(const char *body, const char *model);
+bool anthropic_model_present(const char *body, const char *model);
+
+/* Best effort: network failures / non-2xx / missing fields -> 0. */
+unsigned long agent_fetch_context_limit(const config *cfg);
+provider_probe agent_probe_provider(const config *cfg);
+
+#endif
diff --git a/src/compact.cpp b/src/compact.cpp
new file mode 100644
index 0000000..65e2210
--- /dev/null
+++ b/src/compact.cpp
@@ -0,0 +1,596 @@
+#include "compact.h"
+
+#include <ctype.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* ------------------------------------------------------------------ */
+/* Preamble */
+/* ------------------------------------------------------------------ */
+
+const char *compact_summarizer_preamble(void) {
+ return "You are a context compactor for a terminal coding agent. You are "
+ "given the transcript of the earlier part of that agent's "
+ "conversation, which no longer fits in the context window. Compress "
+ "it into a faithful, dense summary that lets the agent continue "
+ "seamlessly.\n"
+ "\n"
+ "Write ONLY the summary, in Markdown, with these exact sections in "
+ "this order:\n"
+ "\n"
+ "## Goal\n"
+ "What the user is trying to accomplish.\n"
+ "\n"
+ "## Decisions\n"
+ "Choices made and the reasons that still matter.\n"
+ "\n"
+ "## Work done\n"
+ "Concrete steps taken: files touched, commands run, and their "
+ "results.\n"
+ "\n"
+ "## Open issues\n"
+ "Errors, edge cases, or problems that remain unresolved.\n"
+ "\n"
+ "## Next steps\n"
+ "What should happen next.\n"
+ "\n"
+ "## Files\n"
+ "One bullet per file the agent read or edited, as `- <path>`.\n"
+ "\n"
+ "Rules:\n"
+ "- Recall over precision: keep every fact the agent would otherwise "
+ "have to rediscover; drop nothing load-bearing.\n"
+ "- Be factual and terse; never invent details that are not in the "
+ "transcript.\n"
+ "- Discard raw tool output and verbatim file contents; summarize "
+ "their substance instead.\n"
+ "- Keep it under 800 words.";
+}
+
+/* ------------------------------------------------------------------ */
+/* Config */
+/* ------------------------------------------------------------------ */
+
+void compact_config_from(unsigned long context_limit, double compact_threshold,
+ double clear_threshold, compact_config *out) {
+ out->context_limit = context_limit;
+ out->compact_threshold = compact_threshold;
+ out->clear_threshold = clear_threshold;
+}
+
+bool compact_at(const compact_config *c, unsigned long *out) {
+ if (c->compact_threshold <= 0.0)
+ return false;
+ *out = (unsigned long)(c->compact_threshold * (double)c->context_limit);
+ return true;
+}
+
+bool clear_at(const compact_config *c, unsigned long *out) {
+ if (c->clear_threshold <= 0.0)
+ return false;
+ *out = (unsigned long)(c->clear_threshold * (double)c->context_limit);
+ return true;
+}
+
+/* ------------------------------------------------------------------ */
+/* Token estimation */
+/* ------------------------------------------------------------------ */
+
+static size_t trc_bytes(const tool_result_content &c) {
+ if (std::holds_alternative<trc_image>(c))
+ return 8;
+ if (const auto *t = std::get_if<trc_text>(&c))
+ return t->text.size();
+ if (const auto *s = std::get_if<trc_json>(&c))
+ return s->value.size();
+ return 0;
+}
+
+static size_t uc_bytes(const user_part &p) {
+ if (const auto *t = std::get_if<user_text_part>(&p))
+ return t->text.size();
+ const auto &r = std::get<user_tool_result_part>(p);
+ size_t total = r.name.size();
+ for (const auto &c : r.content)
+ total += trc_bytes(c);
+ return total;
+}
+
+static size_t ac_bytes(const assistant_part &p) {
+ if (std::holds_alternative<asst_image_part>(p))
+ return 0;
+ if (const auto *c = std::get_if<asst_tool_call_part>(&p))
+ return c->name.size() + c->args.size();
+ if (const auto *t = std::get_if<asst_text_part>(&p))
+ return t->text.size();
+ if (const auto *r = std::get_if<asst_reasoning_part>(&p))
+ return r->text.size();
+ return 0;
+}
+
+static size_t message_bytes(const message *m) {
+ if (m->is_system())
+ return m->system().text.size();
+ size_t total = 0;
+ if (m->is_user()) {
+ for (const auto &p : m->user().parts)
+ total += uc_bytes(p);
+ } else if (m->is_assistant()) {
+ for (const auto &p : m->assistant().parts)
+ total += ac_bytes(p);
+ }
+ return total;
+}
+
+unsigned long compact_estimate_tokens(const message *msgs, size_t n) {
+ unsigned long long bytes = 0;
+ for (size_t i = 0; i < n; i++)
+ bytes += message_bytes(&msgs[i]);
+ return (unsigned long)((bytes + 3) / 4);
+}
+
+/* ------------------------------------------------------------------ */
+/* Boundary + clearing */
+/* ------------------------------------------------------------------ */
+
+static bool compact_is_pure_text_user(const message *m) {
+ return m->is_user() && m->user().parts.size() == 1 &&
+ std::holds_alternative<user_text_part>(m->user().parts[0]);
+}
+
+size_t compact_keep_boundary(const message *msgs, size_t n) {
+ for (size_t i = n; i-- > 0;) {
+ if (compact_is_pure_text_user(&msgs[i]))
+ return i;
+ }
+ return n;
+}
+
+size_t compact_clear_old_tool_results(message *msgs, size_t n, size_t boundary) {
+ size_t cleared = 0;
+ for (size_t i = 0; i < boundary && i < n; i++) {
+ message *m = &msgs[i];
+ if (!m->is_user())
+ continue;
+ for (auto &p : m->user().parts) {
+ user_tool_result_part *r = std::get_if<user_tool_result_part>(&p);
+ if (!r)
+ continue;
+ bool already = false;
+ for (const auto &c : r->content) {
+ const trc_text *t = std::get_if<trc_text>(&c);
+ if (t && t->text == CLEARED_PLACEHOLDER)
+ already = true;
+ }
+ if (already)
+ continue;
+ r->content.clear();
+ trc_text t;
+ t.text = CLEARED_PLACEHOLDER;
+ r->content.push_back(std::move(t));
+ cleared++;
+ }
+ }
+ return cleared;
+}
+
+/* ------------------------------------------------------------------ */
+/* Transcripts */
+/* ------------------------------------------------------------------ */
+
+static void put_trc(xbuf *b, const tool_result_content &c) {
+ if (std::holds_alternative<trc_image>(c)) {
+ xbuf_put(b, "[image]");
+ return;
+ }
+ if (const auto *t = std::get_if<trc_text>(&c)) {
+ if (!t->text.empty())
+ xbuf_put(b, t->text.c_str());
+ return;
+ }
+ if (const auto *s = std::get_if<trc_json>(&c))
+ if (!s->value.empty())
+ xbuf_put(b, s->value.c_str());
+}
+
+char *compact_transcript(const message *msgs, size_t n) {
+ xbuf b;
+ xbuf_init(&b);
+ for (size_t i = 0; i < n; i++) {
+ const message *m = &msgs[i];
+ if (m->is_system()) {
+ xbuf_put(&b, "System: ");
+ xbuf_put(&b, m->system().text.c_str());
+ xbuf_push(&b, '\n');
+ } else if (m->is_user()) {
+ for (size_t j = 0; j < m->user().parts.size(); j++) {
+ const auto &p = m->user().parts[j];
+ if (const auto *t = std::get_if<user_text_part>(&p)) {
+ xbuf_put(&b, "User: ");
+ xbuf_put(&b, t->text.c_str());
+ xbuf_push(&b, '\n');
+ } else {
+ const auto &r = std::get<user_tool_result_part>(p);
+ xbuf_fmt(&b, "ToolResult(%s): ", r.name.c_str());
+ for (const auto &c : r.content)
+ put_trc(&b, c);
+ xbuf_push(&b, '\n');
+ }
+ }
+ } else if (m->is_assistant()) {
+ for (size_t j = 0; j < m->assistant().parts.size(); j++) {
+ const auto &p = m->assistant().parts[j];
+ if (const auto *t = std::get_if<asst_text_part>(&p)) {
+ xbuf_put(&b, "Assistant: ");
+ xbuf_put(&b, t->text.c_str());
+ xbuf_push(&b, '\n');
+ } else if (const auto *c = std::get_if<asst_tool_call_part>(&p)) {
+ xbuf_fmt(&b, "Assistant tool call %s: %s",
+ c->name.c_str(), c->args.c_str());
+ xbuf_push(&b, '\n');
+ } else if (const auto *r = std::get_if<asst_reasoning_part>(&p)) {
+ xbuf_put(&b, "Assistant reasoning: ");
+ xbuf_put(&b, r->text.c_str());
+ xbuf_push(&b, '\n');
+ }
+ }
+ }
+ }
+ return xbuf_steal(&b);
+}
+
+static void push_md_tool_result(xbuf *b, const tool_result_content &c) {
+ if (const auto *t = std::get_if<trc_text>(&c)) {
+ xbuf_put(b, t->text.c_str());
+ xbuf_put(b, "\n\n");
+ } else if (const auto *s = std::get_if<trc_json>(&c)) {
+ xbuf_put(b, "```json\n");
+ xbuf_put(b, s->value.c_str());
+ xbuf_put(b, "\n```\n\n");
+ } else {
+ xbuf_put(b, "[image]\n\n");
+ }
+}
+
+char *compact_transcript_markdown(const message *msgs, size_t n) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_put(&b, "# llsh transcript\n\n");
+ if (n == 0) {
+ xbuf_put(&b, "_No messages yet._\n");
+ return xbuf_steal(&b);
+ }
+ for (size_t i = 0; i < n; i++) {
+ const message *m = &msgs[i];
+ if (m->is_system()) {
+ xbuf_put(&b, "## System\n\n");
+ xbuf_put(&b, m->system().text.c_str());
+ xbuf_put(&b, "\n\n");
+ } else if (m->is_user()) {
+ for (size_t j = 0; j < m->user().parts.size(); j++) {
+ const auto &p = m->user().parts[j];
+ if (const auto *t = std::get_if<user_text_part>(&p)) {
+ xbuf_put(&b, "## User\n\n");
+ xbuf_put(&b, t->text.c_str());
+ xbuf_put(&b, "\n\n");
+ } else {
+ const auto &r = std::get<user_tool_result_part>(p);
+ xbuf_fmt(&b, "### Tool result: `%s`\n\n",
+ r.name.c_str());
+ for (const auto &c : r.content)
+ push_md_tool_result(&b, c);
+ }
+ }
+ } else if (m->is_assistant()) {
+ for (size_t j = 0; j < m->assistant().parts.size(); j++) {
+ const auto &p = m->assistant().parts[j];
+ if (const auto *t = std::get_if<asst_text_part>(&p)) {
+ xbuf_put(&b, "## Assistant\n\n");
+ xbuf_put(&b, t->text.c_str());
+ xbuf_put(&b, "\n\n");
+ } else if (const auto *c = std::get_if<asst_tool_call_part>(&p)) {
+ xbuf_fmt(&b, "### Tool call: `%s`\n\n",
+ c->name.c_str());
+ if (c->args.empty() || c->args == "null") {
+ xbuf_put(&b, "_no arguments_\n\n");
+ } else {
+ xbuf_put(&b, "```json\n");
+ xbuf_put(&b, c->args.c_str());
+ xbuf_put(&b, "\n```\n\n");
+ }
+ } else if (const auto *r = std::get_if<asst_reasoning_part>(&p)) {
+ if (!r->text.empty()) {
+ xbuf_put(&b, "> **reasoning** ");
+ xbuf_put(&b, r->text.c_str());
+ xbuf_put(&b, "\n\n");
+ }
+ }
+ }
+ }
+ }
+ return xbuf_steal(&b);
+}
+
+/* ------------------------------------------------------------------ */
+/* File-path extraction */
+/* ------------------------------------------------------------------ */
+
+static bool starts_with_i(const char *s, size_t n, const char *prefix) {
+ size_t pn = strlen(prefix);
+ if (n < pn)
+ return false;
+ for (size_t i = 0; i < pn; i++) {
+ char a = s[i];
+ if (a >= 'A' && a <= 'Z')
+ a = (char)(a - 'A' + 'a');
+ if (a != prefix[i])
+ return false;
+ }
+ return true;
+}
+
+void compact_recent_file_paths(const char *summary, strlist *out) {
+ sl_init(out);
+ if (!summary)
+ return;
+ bool in_files = false;
+ const char *p = summary;
+ while (*p) {
+ const char *nl = strchr(p, '\n');
+ size_t linelen = nl ? (size_t)(nl - p) : strlen(p);
+ const char *line_end = p + linelen;
+ size_t tlen;
+ const char *line = xtrim_span(p, linelen, &tlen);
+ if (tlen > 0 && line[0] == '#') {
+ const char *h = line;
+ while (*h == '#')
+ h++;
+ size_t hl;
+ h = xtrim_span(h, (size_t)(line_end - h), &hl);
+ in_files = starts_with_i(h, hl, "files");
+ } else if (in_files && tlen > 0 && (line[0] == '-' || line[0] == '*')) {
+ const char *rest = line + 1;
+ size_t rl;
+ rest = xtrim_span(rest, (size_t)(line_end - rest), &rl);
+ if (rl > 0)
+ sl_push(out, xstrndup(rest, rl));
+ }
+ p = nl ? nl + 1 : p + linelen;
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Compose history */
+/* ------------------------------------------------------------------ */
+
+history *compact_compose_history(const char *summary,
+ const char **file_paths,
+ const char **file_contents, size_t nfiles,
+ const message *tail, size_t ntail) {
+ xbuf body;
+ xbuf_init(&body);
+ xbuf_put(&body,
+ "The earlier conversation was summarized to fit the context "
+ "window. Continue from this summary.\n\n");
+ if (summary) {
+ size_t tlen;
+ const char *s = xtrim_span(summary, strlen(summary), &tlen);
+ xbuf_putn(&body, s, tlen);
+ xbuf_push(&body, '\n');
+ }
+
+ unsigned long total = 0;
+ size_t taken = 0;
+ for (size_t i = 0; i < nfiles && i < MAX_FILES; i++) {
+ if (total >= FILES_CAP)
+ break;
+ const char *content = file_contents[i] ? file_contents[i] : "";
+ size_t cpoints = utf8_len(content);
+ unsigned long take = PER_FILE_CAP;
+ if (take > FILES_CAP - total)
+ take = FILES_CAP - total;
+ if (take > cpoints)
+ take = cpoints;
+ if (take == 0)
+ break;
+ size_t take_bytes = utf8_byte_len(content, take);
+ total += take;
+ taken++;
+ xbuf_fmt(&body, "\n### %s\n```\n", file_paths[i] ? file_paths[i] : "");
+ xbuf_putn(&body, content, take_bytes);
+ xbuf_put(&body, "\n```\n");
+ }
+
+ history *h = (history *) xmalloc(sizeof(*h));
+ new (h) history{};
+ char *body_text = xbuf_steal(&body);
+ h->msgs.push_back(msg_user_text(body_text));
+ free(body_text);
+ for (size_t i = 0; i < ntail; i++)
+ h->msgs.push_back(tail[i]);
+ (void)taken;
+ return h;
+}
+
+/* ------------------------------------------------------------------ */
+/* SelfCompact-style rubric probe (arXiv:2606.23525) */
+/* ------------------------------------------------------------------ */
+
+const char *compact_rubric_preamble(void) {
+ return "You are a careful judge for a terminal coding agent. You are "
+ "given the agent's trajectory and a search rubric of four yes/no "
+ "questions (C1, C2, C3, N1). Answer each question exactly in the "
+ "requested line format, with the evidence it asks for. Do not "
+ "summarize the trajectory, do not restate the questions, and add "
+ "no text beyond the four verdict lines.";
+}
+
+const char *compact_rubric_prompt(void) {
+ return "You are about to decide whether to compress your conversation "
+ "history into a summary that REPLACES the full history above. "
+ "After compression, you continue from only [system, original "
+ "question, assistant summary, user continue]. Compression is "
+ "irreversible: anything not preserved in the summary is gone.\n"
+ "\n"
+ "Compression is safe ONLY when ALL FOUR of the following hold: "
+ "(C1) the trajectory has reached a closed unit (not mid-thought), "
+ "(C2) the essential information is reducible to 3-5 citable "
+ "facts without loss, (C3) something has progressed since the last "
+ "compression, (N1) you are NOT currently stuck in a way "
+ "summarization would mask. Answer C1, C2, C3, N1 honestly. Each "
+ "Y answer requires verbatim evidence quoted from the trajectory "
+ "above; answers without evidence default to N.\n"
+ "\n"
+ "C1 CLOSED-UNIT: The most recent assistant message is a closed "
+ "unit - a completed tool call whose result is now visible, or a "
+ "completed sub-analysis with a clear stopping point. It is NOT "
+ "mid-sentence reasoning (\"Let me now check...\", \"I should "
+ "next look at...\"), and not a half-formulated query. *If Y*, "
+ "quote the closing fragment of the last assistant message. *If "
+ "N*, quote the open fragment that shows the trajectory is "
+ "mid-thought.\n"
+ "C2 SUMMARIZABLE: You can write 3-5 essential facts (with "
+ "verbatim citations from the trajectory) that future-you needs "
+ "to continue after compression. Each fact must be a single "
+ "concrete statement: a name, date, URL, quoted claim, or "
+ "resolved sub-question. Answer N if the trajectory's value is "
+ "dispersed across many small inferences (e.g., a list of dead-"
+ "end queries needed to avoid retries, negative results that "
+ "constrain the hypothesis space) that would be lost without the "
+ "dispersal. *If Y*, list the 3-5 facts numbered, each with a "
+ "verbatim citation in quotes. *If N*, name in one sentence the "
+ "class of information that would be lost.\n"
+ "C3 PROGRESS: Since the most recent compression (or since the "
+ "start of the conversation if none), you have either obtained a "
+ "new concrete fact (name, date, URL, or quoted claim) OR "
+ "refined the sub-question being pursued. *If Y*, name the new "
+ "fact or refined sub-question. *If N*, state that you are "
+ "returning the same state you compressed from.\n"
+ "N1 STUCK: At least 3 of your last 4 tool calls returned no new "
+ "information (i.e., were duplicates or returned already-known "
+ "content). If you have made fewer than 4 tool calls total, "
+ "answer N. *If Y*, name 1 distinct strategy you have NOT yet "
+ "tried (different tool, different query type, different angle "
+ "on the question). *If N*, name one new fact obtained "
+ "recently.\n"
+ "\n"
+ "Output: exactly 4 lines, no preamble or trailing text:\n"
+ "C1: Y/N -- <evidence>\n"
+ "C2: Y/N -- <evidence>\n"
+ "C3: Y/N -- <evidence>\n"
+ "N1: Y/N -- <evidence>";
+}
+
+/* First line whose start matches `label` (case-insensitive, followed by
+ * whitespace or ':'), or NULL. */
+static const char *rubric_line(const char *answer, const char *label) {
+ size_t ll = strlen(label);
+ const char *p = answer;
+ while (*p) {
+ const char *nl = strchr(p, '\n');
+ size_t len = nl ? (size_t)(nl - p) : strlen(p);
+ if (len >= ll && strncasecmp(p, label, ll) == 0 &&
+ (len == ll || p[ll] == ' ' || p[ll] == '\t' || p[ll] == ':'))
+ return p;
+ if (!nl)
+ break;
+ p = nl + 1;
+ }
+ return NULL;
+}
+
+/* Verdict at `line` (just past the label). Sets *evidence to the
+ * remainder of the line after an optional `--` separator. Returns 1 = Y,
+ * 0 = N, -1 = no verdict token. A Y with empty evidence is returned as N
+ * (the paper's "answers without evidence default to N"). */
+static int rubric_verdict(const char *line, const char **evidence) {
+ while (*line == ' ' || *line == '\t')
+ line++;
+ if (*line == ':') {
+ line++;
+ while (*line == ' ' || *line == '\t')
+ line++;
+ }
+ char v = (char)toupper((unsigned char)*line);
+ if (v != 'Y' && v != 'N')
+ return -1;
+ int verdict = (v == 'Y');
+ line++;
+ while (*line == ' ' || *line == '\t')
+ line++;
+ if (line[0] == '-' && line[1] == '-')
+ line += 2;
+ while (*line == ' ' || *line == '\t')
+ line++;
+ *evidence = line;
+ if (verdict) {
+ /* A Y needs real evidence on the same line (the paper's rule). */
+ const char *p = line;
+ while (*p && *p != '\n')
+ p++;
+ if (p == line)
+ verdict = 0;
+ }
+ return verdict;
+}
+
+int compact_rubric_parse(const char *answer, compact_rubric *out,
+ char **err) {
+ if (err)
+ *err = NULL;
+ if (!out)
+ return -1;
+ out->c1 = out->c2 = out->c3 = out->n1 = 0;
+ if (!answer || !*answer) {
+ if (err)
+ *err = xstrdup("rubric: empty verdict");
+ return -1;
+ }
+ struct {
+ const char *label;
+ int *slot;
+ } items[4] = {
+ {"C1", &out->c1},
+ {"C2", &out->c2},
+ {"C3", &out->c3},
+ {"N1", &out->n1},
+ };
+ for (int i = 0; i < 4; i++) {
+ const char *line = rubric_line(answer, items[i].label);
+ if (!line) {
+ if (err)
+ *err = xstrfmt("rubric: missing `%s` verdict line",
+ items[i].label);
+ return -1;
+ }
+ const char *ev = NULL;
+ int verdict = rubric_verdict(line + strlen(items[i].label), &ev);
+ if (verdict < 0) {
+ if (err)
+ *err = xstrfmt("rubric: malformed `%s` verdict line",
+ items[i].label);
+ return -1;
+ }
+ *items[i].slot = verdict;
+ }
+ return 0;
+}
+
+bool compact_rubric_fire(const compact_rubric *r) {
+ return r && r->c1 && r->c2 && r->c3 && !r->n1;
+}
+
+bool compact_rubric_due(unsigned long iteration, unsigned long prompt_tokens,
+ unsigned long summaries_so_far,
+ unsigned long max_summaries,
+ unsigned long rounds_since_probe) {
+ if (iteration < RUBRIC_MIN_ITERATION)
+ return false;
+ if (prompt_tokens < RUBRIC_MIN_TOKENS)
+ return false;
+ if (max_summaries > 0 && summaries_so_far >= max_summaries)
+ return false;
+ if (rounds_since_probe < RUBRIC_MIN_ROUNDS_SINCE_PROBE)
+ return false;
+ return true;
+}
diff --git a/src/compact.h b/src/compact.h
new file mode 100644
index 0000000..c34a17d
--- /dev/null
+++ b/src/compact.h
@@ -0,0 +1,124 @@
+#ifndef LLSH_COMPACT_H
+#define LLSH_COMPACT_H
+
+#include "message.h"
+#include "util.h"
+
+/* Mirrors compact.rs: pure helpers for the two-tier context compaction.
+ * Side-effect free (no I/O) so it is unit-testable; the file reads and the
+ * model call live in the session. */
+
+#define CLEARED_PLACEHOLDER \
+ "[tool output cleared to save context; re-run the tool if you need it]"
+
+/* Re-injected file caps: per-file chars, number of files, total budget. */
+#define PER_FILE_CAP 50000UL
+#define MAX_FILES 5UL
+#define FILES_CAP 100000UL
+
+/* System preamble for the tool-less one-shot summarizer. */
+const char *compact_summarizer_preamble(void);
+
+/* The resolved compaction knobs, carried by a session. */
+typedef struct {
+ unsigned long context_limit; /* 0 disables compaction entirely */
+ double compact_threshold; /* fraction; 0.0 disables the tier */
+ double clear_threshold; /* fraction; 0.0 disables the tier */
+} compact_config;
+
+/* Populate from a config (see config.h). */
+void compact_config_from(unsigned long context_limit, double compact_threshold,
+ double clear_threshold, compact_config *out);
+
+/* Token budget at which the summarization tier fires.
+ * Returns true (and sets *out) when enabled, false when disabled. */
+bool compact_at(const compact_config *c, unsigned long *out);
+bool clear_at(const compact_config *c, unsigned long *out);
+
+/* Which compaction action actually ran. */
+typedef enum {
+ TIER_CLEARED, /* tier 1: stale tool results replaced in place */
+ TIER_SUMMARIZED, /* tier 2: prefix summarized, recent files re-injected */
+ TIER_FALLBACK_CLEAR /* tier 2 requested but summarization failed */
+} compact_tier;
+
+/* What a compaction pass did, for a status line and tests. */
+typedef struct {
+ compact_tier tier;
+ unsigned long tokens_before;
+ unsigned long tokens_after;
+ size_t cleared_tool_results; /* 0 when a summarization ran */
+ size_t files_reinjected; /* 0 unless a summarization ran */
+} compact_report;
+
+/* Rough token count: total bytes / 4 (div_ceil). */
+unsigned long compact_estimate_tokens(const message *msgs, size_t n);
+
+/* Index of the last pure-text user message, or n when there is none. */
+size_t compact_keep_boundary(const message *msgs, size_t n);
+
+/* Replace content of stale tool results (strictly before boundary) with the
+ * placeholder. Idempotent. Returns results cleared on this pass. */
+size_t compact_clear_old_tool_results(message *msgs, size_t n, size_t boundary);
+
+/* Flatten a message slice into a plain-text transcript. Caller frees. */
+char *compact_transcript(const message *msgs, size_t n);
+
+/* Render the conversation as a Markdown transcript. Caller frees. */
+char *compact_transcript_markdown(const message *msgs, size_t n);
+
+/* Extract file paths listed under a `## Files` heading (case-insensitive;
+ * accepts `-` or `*` bullets). Caller frees the strlist. */
+void compact_recent_file_paths(const char *summary, strlist *out);
+
+/* Build the post-compaction history: one user message carrying the summary
+ * and any re-injected files, followed by the kept tail (copied).
+ * Heap-allocated: caller frees with history_free(h); free(h); */
+history *compact_compose_history(const char *summary,
+ const char **file_paths,
+ const char **file_contents, size_t nfiles,
+ const message *tail, size_t ntail);
+
+/* ------------------------------------------------------------------ */
+/* SelfCompact-style rubric probe (arXiv:2606.23525) */
+/* ------------------------------------------------------------------ */
+
+/* Fire-gate thresholds from the paper. max_summaries 0 = unlimited. */
+#define RUBRIC_MIN_ITERATION 3UL
+#define RUBRIC_MIN_TOKENS 40000UL
+#define RUBRIC_MIN_ROUNDS_SINCE_PROBE 2UL
+#define RUBRIC_DEFAULT_MAX_SUMMARIES 1UL
+
+/* Verdict of a rubric probe. Each field: 1 = Y, 0 = N. */
+typedef struct {
+ int c1; /* CLOSED-UNIT: last assistant message is a closed unit */
+ int c2; /* SUMMARIZABLE: value fits 3-5 citable facts */
+ int c3; /* PROGRESS: something advanced since last compression */
+ int n1; /* STUCK: recent tool calls yield nothing new */
+} compact_rubric;
+
+/* System preamble for the rubric judge (kept out of the summarizer's
+ * "write a summary" role). */
+const char *compact_rubric_preamble(void);
+
+/* The probe prompt (the paper's search rubric, adapted to tool calls). */
+const char *compact_rubric_prompt(void);
+
+/* Parse the four `C1/C2/C3/N1: Y|N -- <evidence>` lines. A Y without
+ * evidence on its line is downgraded to N (the paper's rule). `out` must
+ * be non-NULL; 0 on success, -1 with owned *err when a verdict is missing
+ * or malformed. */
+int compact_rubric_parse(const char *answer, compact_rubric *out, char **err);
+
+/* Fire only when C1 and C2 and C3 are all Y and N1 is N. */
+bool compact_rubric_fire(const compact_rubric *r);
+
+/* Gate check: iteration (tool rounds) >= 3, prompt >= 40k tokens,
+ * summaries so far < cap (0 = unlimited), and >= 2 tool rounds since the
+ * last probe. */
+bool compact_rubric_due(unsigned long iteration, unsigned long prompt_tokens,
+ unsigned long summaries_so_far,
+ unsigned long max_summaries,
+ unsigned long rounds_since_probe);
+
+#endif
diff --git a/src/config.cpp b/src/config.cpp
new file mode 100644
index 0000000..4f3d00a
--- /dev/null
+++ b/src/config.cpp
@@ -0,0 +1,732 @@
+#include "config.h"
+
+#include <cjson/cJSON.h>
+#include <ctype.h>
+#include <errno.h>
+#include <getopt.h>
+#include <limits>
+#include <sys/stat.h>
+#include <math.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "compact.h"
+#include "dotagents.h"
+#include "tools.h"
+
+#define DEFAULT_COMPACT_THRESHOLD 0.75
+#define DEFAULT_CLEAR_THRESHOLD 0.5
+#define DEFAULT_MAX_RETRIES 3UL
+#define DEFAULT_SKILL_INDEX_CAP 50UL
+#define DEFAULT_SHELL_TIMEOUT_SECS 120UL
+#define DEFAULT_MAX_OUTPUT_CHARS 16000UL
+#define DEFAULT_MAX_FILE_BYTES 2000000UL
+
+/* The default system preamble. The "Available tools" section is generated
+ * from the tool registry (tools.c) so it can't drift from the actual
+ * tool set. */
+static const char *PREAMBLE_HEAD =
+ "You are an agent operating inside a terminal. You accomplish tasks by "
+ "calling tools.\n"
+ "\n";
+static const char *PREAMBLE_TAIL =
+ "\n"
+ "Guidelines:\n"
+ "- Paths may be relative to the working directory or absolute.\n"
+ "- Inspect before you modify: use read_file, or run_shell with ls / "
+ "rg / grep, to understand existing state.\n"
+ "- Prefer small, verifiable steps. After making changes, verify them "
+ "(e.g. run the build or tests with run_shell).\n"
+ "- Keep run_shell commands safe and scoped to the task; avoid "
+ "destructive commands unless explicitly asked.\n"
+ "- When a task is complete, stop calling tools and reply with a short "
+ "summary of what you did.";
+
+char *default_preamble(void) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_put(&b, PREAMBLE_HEAD);
+ char *tools = tool_preamble_list();
+ xbuf_put(&b, tools);
+ free(tools);
+ xbuf_put(&b, PREAMBLE_TAIL);
+ char *out = xbuf_steal(&b);
+ xbuf_free(&b);
+ return out;
+}
+
+/* ------------------------------------------------------------------ */
+/* Providers */
+/* ------------------------------------------------------------------ */
+
+provider_kind provider_parse(const char *raw, char **err) {
+ xbuf l;
+ xbuf_init(&l);
+ for (const char *p = raw; *p; p++)
+ xbuf_push(&l, (char)tolower((unsigned char)*p));
+ l.data[l.len] = '\0';
+ const char *s = l.data;
+ if (strcmp(s, "openai") == 0 || strcmp(s, "openai-compatible") == 0 ||
+ strcmp(s, "openai_compatible") == 0) {
+ xbuf_free(&l);
+ return PROV_OPENAI;
+ }
+ if (strcmp(s, "anthropic") == 0 || strcmp(s, "claude") == 0) {
+ xbuf_free(&l);
+ return PROV_ANTHROPIC;
+ }
+ if (strcmp(s, "ollama") == 0) {
+ xbuf_free(&l);
+ return PROV_OLLAMA;
+ }
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b, "unknown provider `%s` (expected openai, anthropic, or "
+ "ollama)",
+ raw);
+ xbuf_free(&l);
+ char *r = xbuf_steal(&b);
+ if (err)
+ *err = r;
+ else
+ free(r);
+ return PROV_OPENAI;
+}
+
+const char *provider_name(provider_kind k) {
+ switch (k) {
+ case PROV_OPENAI:
+ return "openai";
+ case PROV_ANTHROPIC:
+ return "anthropic";
+ case PROV_OLLAMA:
+ return "ollama";
+ }
+ return "openai";
+}
+
+static const char *provider_debug(provider_kind k) {
+ switch (k) {
+ case PROV_OPENAI:
+ return "OpenAI";
+ case PROV_ANTHROPIC:
+ return "Anthropic";
+ case PROV_OLLAMA:
+ return "Ollama";
+ }
+ return "OpenAI";
+}
+
+const char *provider_default_model(provider_kind k) {
+ switch (k) {
+ case PROV_OPENAI:
+ return "gpt-5.2";
+ case PROV_ANTHROPIC:
+ return "claude-sonnet-4-6";
+ case PROV_OLLAMA:
+ return "llama3.2";
+ }
+ return "gpt-5.2";
+}
+
+/* ------------------------------------------------------------------ */
+/* CLI parsing */
+/* ------------------------------------------------------------------ */
+
+static void print_usage(FILE *f) {
+ fprintf(
+ f,
+ "llsh %s\n"
+ "llsh — a terminal LLM agent harness built on rig\n"
+ "\n"
+ "Usage: llsh [OPTIONS] [PROMPT]\n"
+ "\n"
+ "Arguments:\n"
+ " [PROMPT] One-shot prompt. If omitted (and stdin is a TTY), "
+ "launches the interactive REPL.\n"
+ "\n"
+ "Options:\n"
+ " --model <MODEL> Model name, e.g. `gpt-5.2`, "
+ "`claude-sonnet-4-6`, `llama3.2`; or a `.agents/models.json` "
+ "preset name (which also brings that preset's "
+ "provider/base_url/api_key). Overrides the preset's `model`; "
+ "defaults per provider when unset\n"
+ " --provider <PROVIDER> Model provider: `openai`, "
+ "`anthropic`, or `ollama`. `openai` also works against any "
+ "OpenAI-compatible endpoint via `--base-url`\n"
+ " --base-url <BASE_URL> Base URL override. For `openai` "
+ "this targets any OpenAI-compatible endpoint (LM Studio, vLLM, "
+ "Ollama's /v1, llama.cpp, ...); for `ollama` this is the Ollama "
+ "server address (default http://localhost:11434)\n"
+ " --api-key <API_KEY> API key. Overrides the preset's "
+ "`api_key`\n"
+ " -C, --cwd <CWD> Working directory the agent's file and shell "
+ "tools operate in\n"
+ " --preamble <PREAMBLE> System preamble for the agent\n"
+ " --max-turns <MAX_TURNS> Maximum model calls (including "
+ "tool-call continuations) per user turn. `0` (the default) is "
+ "unbounded\n"
+ " --max-retries <MAX_RETRIES> Number of times a transient "
+ "provider failure (timeout, 429, 5xx) is retried with exponential "
+ "backoff before the turn fails. `0` disables retry\n"
+ " --context-limit <CONTEXT_LIMIT> Model context window in "
+ "tokens. `0` (the default) disables context compaction; a window "
+ "reported by the provider's endpoint is adopted when available, "
+ "otherwise this value is used\n"
+ " --compact-threshold <COMPACT_THRESHOLD> Fraction of "
+ "`context_limit` at which earlier context is summarized. `0` "
+ "disables the tier; otherwise the value must be in `(0, 1]`\n"
+ " --clear-threshold <CLEAR_THRESHOLD> Fraction of "
+ "`context_limit` at which stale tool results are cleared. `0` "
+ "disables the tier; otherwise the value must be in `(0, 1]`\n"
+ " --no-rubric-compaction Disable the SelfCompact rubric "
+ "probe that compacts the trajectory at a judged-safe point "
+ "(independent of the token thresholds above)\n"
+ " --rubric-max-summaries <N> Cap on rubric-triggered "
+ "compactions per session. The default is `1` (the paper's cap); "
+ "`0` means unlimited\n"
+ " --verbose Verbose, structured logging to stderr (one JSON "
+ "object per event: turn lifecycle, tool calls, compaction, "
+ "retries, and errors)\n"
+ " --debug Write the whole conversation history to "
+ "`debug.json` (in the working directory) after every turn and "
+ "again on exit\n"
+ " --skill-index-cap <SKILL_INDEX_CAP> Maximum number of "
+ "skills listed in the preamble's skills index\n"
+ " --shell-timeout-secs <SHELL_TIMEOUT_SECS> `run_shell` "
+ "timeout in seconds (the whole process tree is killed on expiry)\n"
+ " --max-output-chars <MAX_OUTPUT_CHARS> Cap on tool/shell "
+ "output text, in characters (longer is truncated)\n"
+ " --max-file-bytes <MAX_FILE_BYTES> Cap on the size of a "
+ "file `read_file` will read, in bytes\n"
+ " --resume <PATH> Resume a session saved by `/save`: restore "
+ "its conversation, model, preset, sub-agent, and working "
+ "directory. The saved model/config values are authoritative, so "
+ "`--model` / `--provider` / `--base-url` / `--api-key` are "
+ "ignored when this is set\n"
+ " --allow-mcp Trust and start MCP servers from .agents/mcp.json\n"
+ " --shell-allow <COMMAND> Allow this run_shell command (repeatable)\n"
+ " --shell-deny <COMMAND> Deny this run_shell command (repeatable)\n"
+ " --confirm-destructive Prompt before recognized destructive shell commands\n"
+ " --no-confirm-destructive Disable the destructive-command prompt\n"
+ " -h, --help Print help\n"
+ " -V, --version Print version\n",
+ LLSH_VERSION);
+}
+
+static int parse_ulong(const char *s, unsigned long *out, char **err,
+ const char *flag) {
+ /* strtoull silently accepts a leading '-' and wraps on overflow; reject
+ * both explicitly so `--max-turns -1` does not become ULONG_MAX. */
+ errno = 0;
+ char *endp = NULL;
+ unsigned long v = strtoull(s, &endp, 10);
+ if (s[0] == '-' || endp == s || *endp != '\0' || errno == ERANGE) {
+ if (err) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b, "invalid value for %s: `%s`", flag, s);
+ *err = xbuf_steal(&b);
+ }
+ return -1;
+ }
+ *out = v;
+ return 0;
+}
+
+static int parse_fraction_arg(const char *s, double *out, char **err,
+ const char *flag) {
+ errno = 0;
+ char *endp = NULL;
+ double value = strtod(s, &endp);
+ if (endp == s || *endp != '\0' || errno == ERANGE || !isfinite(value) ||
+ value < 0.0 || value > 1.0) {
+ if (err)
+ *err = xstrfmt("invalid value for %s: `%s` (expected 0 or a finite value in (0, 1])",
+ flag, s);
+ return -1;
+ }
+ *out = value;
+ return 0;
+}
+
+int cli_parse(int argc, char **argv, cli *out, char **err) {
+ memset(out, 0, sizeof *out);
+ sl_init(&out->shell_allowlist);
+ sl_init(&out->shell_denylist);
+
+ enum {
+ OPT_MODEL = 256,
+ OPT_PROVIDER,
+ OPT_BASE_URL,
+ OPT_API_KEY,
+ OPT_CWD,
+ OPT_PREAMBLE,
+ OPT_MAX_TURNS,
+ OPT_MAX_RETRIES,
+ OPT_CONTEXT_LIMIT,
+ OPT_COMPACT_THRESHOLD,
+ OPT_CLEAR_THRESHOLD,
+ OPT_VERBOSE,
+ OPT_DEBUG,
+ OPT_NO_RUBRIC,
+ OPT_RUBRIC_MAX,
+ OPT_SKILL_INDEX_CAP,
+ OPT_SHELL_TIMEOUT,
+ OPT_MAX_OUTPUT,
+ OPT_MAX_FILE,
+ OPT_RESUME,
+ OPT_ALLOW_MCP,
+ OPT_SHELL_ALLOW,
+ OPT_SHELL_DENY,
+ OPT_CONFIRM_DESTRUCTIVE,
+ OPT_NO_CONFIRM_DESTRUCTIVE,
+ };
+ static const struct option long_opts[] = {
+ {"model", required_argument, 0, OPT_MODEL},
+ {"provider", required_argument, 0, OPT_PROVIDER},
+ {"base-url", required_argument, 0, OPT_BASE_URL},
+ {"api-key", required_argument, 0, OPT_API_KEY},
+ {"cwd", required_argument, 0, OPT_CWD},
+ {"preamble", required_argument, 0, OPT_PREAMBLE},
+ {"max-turns", required_argument, 0, OPT_MAX_TURNS},
+ {"max-retries", required_argument, 0, OPT_MAX_RETRIES},
+ {"context-limit", required_argument, 0, OPT_CONTEXT_LIMIT},
+ {"compact-threshold", required_argument, 0, OPT_COMPACT_THRESHOLD},
+ {"clear-threshold", required_argument, 0, OPT_CLEAR_THRESHOLD},
+ {"verbose", no_argument, 0, OPT_VERBOSE},
+ {"debug", no_argument, 0, OPT_DEBUG},
+ {"no-rubric-compaction", no_argument, 0, OPT_NO_RUBRIC},
+ {"rubric-max-summaries", required_argument, 0, OPT_RUBRIC_MAX},
+ {"skill-index-cap", required_argument, 0, OPT_SKILL_INDEX_CAP},
+ {"shell-timeout-secs", required_argument, 0, OPT_SHELL_TIMEOUT},
+ {"max-output-chars", required_argument, 0, OPT_MAX_OUTPUT},
+ {"max-file-bytes", required_argument, 0, OPT_MAX_FILE},
+ {"resume", required_argument, 0, OPT_RESUME},
+ {"allow-mcp", no_argument, 0, OPT_ALLOW_MCP},
+ {"shell-allow", required_argument, 0, OPT_SHELL_ALLOW},
+ {"shell-deny", required_argument, 0, OPT_SHELL_DENY},
+ {"confirm-destructive", no_argument, 0, OPT_CONFIRM_DESTRUCTIVE},
+ {"no-confirm-destructive", no_argument, 0, OPT_NO_CONFIRM_DESTRUCTIVE},
+ {"help", no_argument, 0, 'h'},
+ {"version", no_argument, 0, 'V'},
+ {0, 0, 0, 0},
+ };
+
+ int c;
+ int prompt_set = 0;
+ optind = 1;
+ while ((c = getopt_long(argc, argv, "C:hV", long_opts, NULL)) != -1) {
+ switch (c) {
+ case OPT_MODEL:
+ out->model = xstrdup(optarg);
+ break;
+ case OPT_PROVIDER:
+ out->provider = xstrdup(optarg);
+ break;
+ case OPT_BASE_URL:
+ out->base_url = xstrdup(optarg);
+ break;
+ case OPT_API_KEY:
+ out->api_key = xstrdup(optarg);
+ break;
+ case OPT_CWD:
+ case 'C':
+ out->cwd = xstrdup(optarg);
+ break;
+ case OPT_PREAMBLE:
+ out->preamble = xstrdup(optarg);
+ break;
+ case OPT_MAX_TURNS:
+ if (parse_ulong(optarg, &out->max_turns, err, "--max-turns") != 0)
+ return -1;
+ out->has_max_turns = true;
+ break;
+ case OPT_MAX_RETRIES:
+ if (parse_ulong(optarg, &out->max_retries, err,
+ "--max-retries") != 0)
+ return -1;
+ out->has_max_retries = true;
+ break;
+ case OPT_CONTEXT_LIMIT:
+ if (parse_ulong(optarg, &out->context_limit, err,
+ "--context-limit") != 0)
+ return -1;
+ out->has_context_limit = true;
+ break;
+ case OPT_COMPACT_THRESHOLD:
+ if (parse_fraction_arg(optarg, &out->compact_threshold, err,
+ "--compact-threshold") != 0)
+ return -1;
+ out->has_compact_threshold = true;
+ break;
+ case OPT_CLEAR_THRESHOLD:
+ if (parse_fraction_arg(optarg, &out->clear_threshold, err,
+ "--clear-threshold") != 0)
+ return -1;
+ out->has_clear_threshold = true;
+ break;
+ case OPT_VERBOSE:
+ out->verbose = true;
+ break;
+ case OPT_DEBUG:
+ out->debug = true;
+ break;
+ case OPT_NO_RUBRIC:
+ out->no_rubric = true;
+ break;
+ case OPT_RUBRIC_MAX:
+ if (parse_ulong(optarg, &out->rubric_max_summaries, err,
+ "--rubric-max-summaries") != 0)
+ return -1;
+ out->has_rubric_max_summaries = true;
+ break;
+ case OPT_SKILL_INDEX_CAP:
+ if (parse_ulong(optarg, &out->skill_index_cap, err,
+ "--skill-index-cap") != 0)
+ return -1;
+ out->has_skill_index_cap = true;
+ break;
+ case OPT_SHELL_TIMEOUT:
+ if (parse_ulong(optarg, &out->shell_timeout_secs, err,
+ "--shell-timeout-secs") != 0)
+ return -1;
+ out->has_shell_timeout_secs = true;
+ break;
+ case OPT_MAX_OUTPUT:
+ if (parse_ulong(optarg, &out->max_output_chars, err,
+ "--max-output-chars") != 0)
+ return -1;
+ out->has_max_output_chars = true;
+ break;
+ case OPT_MAX_FILE:
+ if (parse_ulong(optarg, &out->max_file_bytes, err,
+ "--max-file-bytes") != 0)
+ return -1;
+ out->has_max_file_bytes = true;
+ break;
+ case OPT_RESUME:
+ out->resume = xstrdup(optarg);
+ break;
+ case OPT_ALLOW_MCP:
+ out->allow_mcp = true;
+ break;
+ case OPT_SHELL_ALLOW:
+ if (!optarg[0]) {
+ if (err)
+ *err = xstrdup("--shell-allow requires a non-empty command");
+ return -1;
+ }
+ sl_push_copy(&out->shell_allowlist, optarg);
+ break;
+ case OPT_SHELL_DENY:
+ if (!optarg[0]) {
+ if (err)
+ *err = xstrdup("--shell-deny requires a non-empty command");
+ return -1;
+ }
+ sl_push_copy(&out->shell_denylist, optarg);
+ break;
+ case OPT_CONFIRM_DESTRUCTIVE:
+ out->confirm_destructive = true;
+ out->has_confirm_destructive = true;
+ break;
+ case OPT_NO_CONFIRM_DESTRUCTIVE:
+ out->confirm_destructive = false;
+ out->has_confirm_destructive = true;
+ break;
+ case 'h':
+ print_usage(stdout);
+ return CLI_HELP;
+ case 'V':
+ fprintf(stdout, "llsh %s\n", LLSH_VERSION);
+ return CLI_HELP;
+ case '?':
+ print_usage(stderr);
+ if (err)
+ *err = xstrdup("try --help");
+ return -1;
+ default:
+ /* getopt_long returns '?' for unknown options; anything else
+ * here is a short option we did not register. */
+ print_usage(stderr);
+ if (err)
+ *err = xstrdup("try --help");
+ return -1;
+ }
+ }
+ for (int i = optind; i < argc; i++) {
+ if (prompt_set) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b, "unexpected argument `%s`", argv[i]);
+ char *r = xbuf_steal(&b);
+ if (err)
+ *err = r;
+ else
+ free(r);
+ return -1;
+ }
+ out->prompt = xstrdup(argv[i]);
+ prompt_set = 1;
+ }
+ return 0;
+}
+
+void cli_free(cli *c) {
+ free(c->prompt);
+ free(c->model);
+ free(c->provider);
+ free(c->base_url);
+ free(c->api_key);
+ free(c->cwd);
+ free(c->preamble);
+ free(c->resume);
+ sl_free(&c->shell_allowlist);
+ sl_free(&c->shell_denylist);
+}
+
+
+/* ------------------------------------------------------------------ */
+/* Merge + validation */
+/* ------------------------------------------------------------------ */
+
+static int validate_fraction(const char *name, double value, char **err) {
+ if (value == 0.0)
+ return 0;
+ if (isnan(value) || isinf(value) || value <= 0.0 || value > 1.0) {
+ if (err) {
+ xbuf b;
+ xbuf_init(&b);
+ const char *rep;
+ if (isnan(value))
+ rep = "NaN";
+ else if (value > 0)
+ rep = "inf";
+ else
+ rep = "-inf";
+ xbuf_fmt(&b, "%s must be 0 or in (0, 1], got %s", name, rep);
+ *err = xbuf_steal(&b);
+ }
+ return -1;
+ }
+ return 0;
+}
+
+static int validate_compaction(const config *c, char **err) {
+ if (validate_fraction("compact_threshold", c->compact_threshold, err) != 0)
+ return -1;
+ if (validate_fraction("clear_threshold", c->clear_threshold, err) != 0)
+ return -1;
+ if (c->context_limit > 0 && c->compact_threshold > 0.0 &&
+ c->clear_threshold > 0.0 &&
+ c->clear_threshold >= c->compact_threshold) {
+ if (err) {
+ char *ct = dtoa_dup(c->clear_threshold);
+ char *pt = dtoa_dup(c->compact_threshold);
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b,
+ "clear_threshold (%s) must be less than "
+ "compact_threshold (%s)",
+ ct, pt);
+ *err = xbuf_steal(&b);
+ free(ct);
+ free(pt);
+ }
+ return -1;
+ }
+ return 0;
+}
+
+int config_require_key_or_base(const config *c, char **err) {
+ if (c->provider != PROV_OLLAMA && !c->base_url && !c->api_key) {
+ if (err) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b,
+ "no API key for provider `%s`: set `api_key` in "
+ "`.agents/models.json` or pass --api-key",
+ provider_debug(c->provider));
+ *err = xbuf_steal(&b);
+ }
+ return -1;
+ }
+ return 0;
+}
+
+void config_free(config *c) {
+ free(c->model);
+ free(c->base_url);
+ free(c->api_key);
+ free(c->preamble);
+ sl_free(&c->shell_allowlist);
+ sl_free(&c->shell_denylist);
+}
+
+int config_merge(const cli *cli, config *out, char **err) {
+ memset(out, 0, sizeof *out);
+ sl_init(&out->shell_allowlist);
+ sl_init(&out->shell_denylist);
+ out->confirm_destructive = cli->confirm_destructive;
+ for (size_t i = 0; i < cli->shell_allowlist.len; i++)
+ sl_push_copy(&out->shell_allowlist, cli->shell_allowlist.items[i]);
+ for (size_t i = 0; i < cli->shell_denylist.len; i++)
+ sl_push_copy(&out->shell_denylist, cli->shell_denylist.items[i]);
+
+ out->provider = provider_parse(cli->provider ? cli->provider : "openai",
+ err);
+ if (err && *err)
+ return -1;
+
+ out->model = xstrdup(cli->model ? cli->model
+ : provider_default_model(out->provider));
+
+ out->base_url = cli->base_url ? xstrdup(cli->base_url) : NULL;
+ out->api_key = cli->api_key ? xstrdup(cli->api_key) : NULL;
+
+ out->preamble = cli->preamble ? xstrdup(cli->preamble)
+ : default_preamble();
+
+ out->max_turns = cli->has_max_turns ? cli->max_turns : 0;
+ out->max_retries = cli->has_max_retries ? cli->max_retries
+ : DEFAULT_MAX_RETRIES;
+ out->context_limit =
+ cli->has_context_limit ? cli->context_limit : 0;
+ out->compact_threshold = cli->has_compact_threshold ? cli->compact_threshold
+ : DEFAULT_COMPACT_THRESHOLD;
+ out->clear_threshold = cli->has_clear_threshold ? cli->clear_threshold
+ : DEFAULT_CLEAR_THRESHOLD;
+
+ out->rubric_enabled = !cli->no_rubric;
+ out->rubric_max_summaries = cli->has_rubric_max_summaries
+ ? cli->rubric_max_summaries
+ : RUBRIC_DEFAULT_MAX_SUMMARIES;
+
+ out->verbose = cli->verbose;
+
+ out->skill_index_cap =
+ cli->has_skill_index_cap ? cli->skill_index_cap
+ : DEFAULT_SKILL_INDEX_CAP;
+ if (out->skill_index_cap < 1)
+ out->skill_index_cap = 1;
+ out->shell_timeout_secs =
+ cli->has_shell_timeout_secs ? cli->shell_timeout_secs
+ : DEFAULT_SHELL_TIMEOUT_SECS;
+ if (out->shell_timeout_secs < 1)
+ out->shell_timeout_secs = 1;
+ out->max_output_chars =
+ cli->has_max_output_chars ? cli->max_output_chars
+ : DEFAULT_MAX_OUTPUT_CHARS;
+ if (out->max_output_chars < 1)
+ out->max_output_chars = 1;
+ out->max_file_bytes =
+ cli->has_max_file_bytes ? cli->max_file_bytes
+ : DEFAULT_MAX_FILE_BYTES;
+ if (out->max_file_bytes < 1)
+ out->max_file_bytes = 1;
+
+ if (validate_compaction(out, err) != 0)
+ return -1;
+ /* The api_key/base_url requirement is checked by the caller AFTER a
+ * models.json preset may have supplied them (precedence: CLI flag >
+ * models.json preset > provider defaults). */
+ return 0;
+}
+
+int config_from_cli(const cli *cli, config *out, char **err) {
+ const char *cwd = cli->cwd ? cli->cwd : ".";
+
+ if (config_merge(cli, out, err) != 0) {
+ config_free(out);
+ return -1;
+ }
+
+ char *cwd_exp = expand_tilde(cwd);
+ if (chdir(cwd_exp) != 0) {
+ if (err) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b, "failed to enter working directory `%s`: %s",
+ cwd_exp, strerror(errno));
+ *err = xbuf_steal(&b);
+ }
+ free(cwd_exp);
+ config_free(out);
+ return -1;
+ }
+ free(cwd_exp);
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* models.json presets */
+/* ------------------------------------------------------------------ */
+
+char *config_apply_models_preset(config *cfg, const models_file *models,
+ const cli *cli, char **err) {
+ const char *name = models->default_name;
+ if (!name || !*name) {
+ if (err)
+ *err = xstrdup("models.json declares no `default` preset");
+ return NULL;
+ }
+ const model_preset *preset = models_file_get(models, name);
+ if (!preset) {
+ if (err) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_put(&b, "models.json default preset `");
+ xbuf_put(&b, name);
+ xbuf_put(&b, "` is not in presets (");
+ for (size_t i = 0; i < models->preset_count; i++) {
+ if (i)
+ xbuf_put(&b, ", ");
+ xbuf_put(&b, models->presets[i].name ? models->presets[i].name : "");
+ }
+ xbuf_put(&b, ")");
+ *err = xbuf_steal(&b);
+ }
+ return NULL;
+ }
+ if (!cli->provider && preset->provider) {
+ cfg->provider = provider_parse(preset->provider, err);
+ if (err && *err)
+ return NULL;
+ }
+ if (!cli->model && preset->model) {
+ free(cfg->model);
+ cfg->model = xstrdup(preset->model);
+ }
+ if (!cli->base_url && preset->base_url) {
+ free(cfg->base_url);
+ cfg->base_url = xstrdup(preset->base_url);
+ }
+ if (!cli->api_key && preset->api_key) {
+ free(cfg->api_key);
+ cfg->api_key = xstrdup(preset->api_key);
+ }
+ if (!cli->has_max_turns && preset->has_max_turns &&
+ preset->max_turns > 0)
+ cfg->max_turns = preset->max_turns;
+ if (!cli->has_context_limit && preset->has_context_limit)
+ cfg->context_limit = preset->context_limit;
+
+ if (cfg->provider != PROV_OLLAMA && !cfg->base_url && !cfg->api_key) {
+ if (err) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b,
+ "no API key for provider `%s` after applying preset "
+ "`%s`: set `api_key` in the preset/config file or "
+ "pass --api-key",
+ provider_debug(cfg->provider), name);
+ *err = xbuf_steal(&b);
+ }
+ return NULL;
+ }
+ if (validate_compaction(cfg, err) != 0)
+ return NULL;
+ return xstrdup(name);
+}
diff --git a/src/config.h b/src/config.h
new file mode 100644
index 0000000..d71a78b
--- /dev/null
+++ b/src/config.h
@@ -0,0 +1,106 @@
+#ifndef LLSH_CONFIG_H
+#define LLSH_CONFIG_H
+
+#include "dotagents.h"
+#include "util.h"
+
+/* Mirrors config.rs: CLI + file config + merged Config. */
+
+typedef enum {
+ PROV_OPENAI,
+ PROV_ANTHROPIC,
+ PROV_OLLAMA,
+} provider_kind;
+
+#define LLSH_VERSION "0.1.0"
+
+typedef struct {
+ char *prompt;
+ char *model;
+ char *provider;
+ char *base_url;
+ char *api_key;
+ char *cwd;
+ char *preamble;
+ unsigned long max_turns;
+ bool has_max_turns;
+ unsigned long max_retries;
+ bool has_max_retries;
+ unsigned long context_limit;
+ bool has_context_limit;
+ double compact_threshold;
+ bool has_compact_threshold;
+ double clear_threshold;
+ bool has_clear_threshold;
+ bool verbose;
+ bool allow_mcp;
+ bool confirm_destructive;
+ bool has_confirm_destructive;
+ strlist shell_allowlist;
+ strlist shell_denylist;
+ bool debug;
+ bool no_rubric; /* --no-rubric-compaction */
+ unsigned long rubric_max_summaries;
+ bool has_rubric_max_summaries;
+ unsigned long skill_index_cap;
+ bool has_skill_index_cap;
+ unsigned long shell_timeout_secs;
+ bool has_shell_timeout_secs;
+ unsigned long max_output_chars;
+ bool has_max_output_chars;
+ unsigned long max_file_bytes;
+ bool has_max_file_bytes;
+ char *resume;
+} cli;
+
+typedef struct {
+ provider_kind provider;
+ char *model;
+ char *base_url; /* may be NULL */
+ char *api_key; /* may be NULL */
+ char *preamble;
+ unsigned long max_turns;
+ unsigned long max_retries;
+ unsigned long context_limit;
+ double compact_threshold;
+ double clear_threshold;
+ bool rubric_enabled;
+ unsigned long rubric_max_summaries; /* 0 = unlimited */
+ bool verbose;
+ unsigned long skill_index_cap;
+ unsigned long shell_timeout_secs;
+ unsigned long max_output_chars;
+ unsigned long max_file_bytes;
+ strlist shell_allowlist;
+ strlist shell_denylist;
+ bool confirm_destructive;
+} config;
+
+/* Parse argv (without program name). 0 on success; -2 prints help and returns
+ * CLI_HELP (call should exit); -1 error. */
+#define CLI_HELP (-2)
+int cli_parse(int argc, char **argv, cli *out, char **err);
+void cli_free(cli *c);
+
+/* Merge CLI + file with precedence CLI > file > defaults. Changes CWD. */
+/* Build the merged config directly from CLI arguments (the former
+ * config-file layer was removed; connection presets live in
+ * `.agents/models.json`). */
+int config_from_cli(const cli *cli, config *out, char **err);
+int config_merge(const cli *cli, config *out, char **err);
+void config_free(config *c);
+
+/* Reject key-requiring providers with neither api_key nor base_url. Run
+ * after a models.json preset may have supplied them (the merge itself no
+ * longer checks). */
+int config_require_key_or_base(const config *c, char **err);
+provider_kind provider_parse(const char *raw, char **err);
+const char *provider_name(provider_kind k);
+const char *provider_default_model(provider_kind k);
+
+/* Apply a models.json `default` preset (see dotagents). Returns preset name
+ * (caller frees) or NULL + *err. */
+char *config_apply_models_preset(config *cfg, const models_file *models,
+ const cli *cli, char **err);
+
+#endif
diff --git a/src/escape.c b/src/escape.c
new file mode 100644
index 0000000..de6e1bd
--- /dev/null
+++ b/src/escape.c
@@ -0,0 +1,5 @@
+/* escape.c — translation unit for the escape.h ANSI escape library.
+ * escape.h is a hardlink into ../header-libraries; the macro interface is
+ * header-only, this file provides the esc_* functions. */
+#define ESCAPE_IMPLEMENTATION
+#include "escape.h"
diff --git a/src/goal.cpp b/src/goal.cpp
new file mode 100644
index 0000000..36aa271
--- /dev/null
+++ b/src/goal.cpp
@@ -0,0 +1,241 @@
+#include "goal.h"
+
+#include <ctype.h>
+#include <stdlib.h>
+#include <string.h>
+
+static const char *const GOAL_PREAMBLE =
+ "You are the reviewer for a terminal coding agent that is working toward a "
+ "goal. You are given the goal — a success condition — and the agent's most "
+ "recent reply. Decide whether the goal is fully met.\n"
+ "\n"
+ "Reply with exactly two lines:\n"
+ "1. Either `MET` or `NOT MET` — your verdict.\n"
+ "2. One short sentence: if met, what was accomplished; if not, the single "
+ "most important gap to fix next.\n"
+ "\n"
+ "Be strict: the goal is met only when the stated success condition is "
+ "clearly satisfied — not merely started, in progress, or partially done. "
+ "Do not give the agent the benefit of the doubt, and do not add commentary "
+ "beyond the two lines.";
+
+void goal_spec_free(goal_spec *s)
+{
+ if (!s)
+ return;
+ free(s->criteria);
+ s->criteria = NULL;
+}
+
+void goal_report_free(goal_report *r)
+{
+ if (!r)
+ return;
+ free(r->criteria);
+ free(r);
+}
+
+const char *goal_evaluator_preamble(void)
+{
+ return GOAL_PREAMBLE;
+}
+
+static bool str_eq_i(const char *s, size_t n, const char *word)
+{
+ size_t wn = strlen(word);
+ if (n != wn)
+ return false;
+ for (size_t i = 0; i < wn; i++) {
+ char a = s[i], b = word[i];
+ if (a >= 'a' && a <= 'z')
+ a = (char)(a - 'a' + 'A');
+ if (b >= 'a' && b <= 'z')
+ b = (char)(b - 'a' + 'A');
+ if (a != b)
+ return false;
+ }
+ return true;
+}
+
+static int parse_tries(const char *s, size_t n, unsigned long *out, char **err)
+{
+ size_t tlen;
+ const char *t = xtrim_span(s, n, &tlen);
+ size_t digits = 0;
+ while (digits < tlen && t[digits] >= '0' && t[digits] <= '9')
+ digits++;
+ if (digits == 0) {
+ *err = xstrdup(
+ "expected a number of tries after `stop after`, e.g. "
+ "`stop after 5 tries`");
+ return 0;
+ }
+ /* After the digits: end of input, or the word `tries` as a whole word.
+ * "5xyz" is rejected; "5 tries" is the canonical form. */
+ size_t rest = digits;
+ while (rest < tlen && isspace((unsigned char)t[rest]))
+ rest++;
+ if (rest != tlen) {
+ size_t wn = 5;
+ bool word_ok = (tlen - rest) >= wn;
+ if (word_ok) {
+ const char *w = "tries";
+ for (size_t i = 0; i < wn; i++) {
+ char a = t[rest + i], b = w[i];
+ if (a >= 'a' && a <= 'z')
+ a = (char)(a - 'a' + 'A');
+ if (b >= 'a' && b <= 'z')
+ b = (char)(b - 'a' + 'A');
+ if (a != b) {
+ word_ok = false;
+ break;
+ }
+ }
+ }
+ if (!word_ok || ((tlen - rest) > wn &&
+ isalpha((unsigned char)t[rest + wn]))) {
+ *err = xstrdup(
+ "expected a number of tries after `stop after`, e.g. "
+ "`stop after 5 tries`");
+ return 0;
+ }
+ }
+ unsigned long value = 0;
+ for (size_t i = 0; i < digits; i++) {
+ value = value * 10UL + (unsigned long)(t[i] - '0');
+ if (value > 4294967295UL) {
+ *err = xstrdup(
+ "expected a number of tries after `stop after`, e.g. "
+ "`stop after 5 tries`");
+ return 0;
+ }
+ }
+ if (value == 0) {
+ *err = xstrdup("the goal needs at least 1 try");
+ return 0;
+ }
+ *out = value;
+ return 1;
+}
+
+int goal_parse(const char *input, goal_spec *out, char **err)
+{
+ /* Zero on entry so a failed parse leaves a freeable, empty spec. */
+ memset(out, 0, sizeof *out);
+ const char *src = input ? input : "";
+ size_t slen = strlen(src);
+ size_t tlen;
+ const char *t = xtrim_span(src, slen, &tlen);
+ if (tlen == 0) {
+ *err = xstrdup(
+ "give the goal its success criteria, e.g. "
+ "`/goal all tests pass, stop after 5 tries`");
+ return 0;
+ }
+
+ const char marker[] = "stop after";
+ size_t mlen = sizeof(marker) - 1;
+ /* rfind: last occurrence of marker in [t, t+tlen). */
+ const char *hit = NULL;
+ const char *p = t;
+ while (tlen - (size_t)(p - t) >= mlen) {
+ void *m = memmem(p, tlen - (size_t)(p - t), marker, mlen);
+ if (!m)
+ break;
+ hit = (const char* ) m;
+ p = (const char *)m + 1;
+ }
+
+ if (!hit) {
+ out->criteria = xstrndup(t, tlen);
+ out->max_tries = GOAL_DEFAULT_MAX_TRIES;
+ return 1;
+ }
+
+ size_t pre_len = (size_t)(hit - t);
+ size_t clen;
+ const char *crit = xtrim_span(t, pre_len, &clen);
+ while (clen > 0 && crit[clen - 1] == ',')
+ clen--;
+ crit = xtrim_span(crit, clen, &clen);
+ if (clen == 0) {
+ *err = xstrdup(
+ "give the goal its success criteria before `stop after`, e.g. "
+ "`/goal all tests pass, stop after 5 tries`");
+ return 0;
+ }
+ const char *tail = hit + mlen;
+ if (!parse_tries(tail, tlen - (size_t)(tail - t), &out->max_tries, err))
+ return 0;
+ out->criteria = xstrndup(crit, clen);
+ return 1;
+}
+
+char *goal_prompt(const goal_spec *spec, unsigned long attempt,
+ const char *feedback)
+{
+ const char *crit = spec->criteria ? spec->criteria : "";
+ if (attempt <= 1)
+ return xstrfmt(
+ "Work toward this goal until it is fully met:\n\n%s\n\n"
+ "Judge your own progress, verify it, and keep going if it is not "
+ "done. When you are confident the goal is fully met, stop and say "
+ "what you did.",
+ crit);
+ const char *fb = feedback ? feedback : "no specific feedback was given";
+ return xstrfmt(
+ "You are on attempt %lu of %lu toward this goal:\n\n%s\n\n"
+ "A reviewer judged the goal NOT yet met: %s\n\n"
+ "Address that gap and keep working. When you are confident the goal is "
+ "fully met, stop and say what you did.",
+ attempt, spec->max_tries, crit, fb);
+}
+
+/* True if `needle` occurs in `hay` as a whole word (A-Z only; `hay` is
+ * already upper-cased). Prevents "MET" matching inside "UNMET". */
+static bool has_word(const char *hay, const char *needle) {
+ size_t nl = strlen(needle);
+ const char *p = hay;
+ while ((p = strstr(p, needle)) != NULL) {
+ bool left_ok = (p == hay) || (p[-1] < 'A' || p[-1] > 'Z');
+ bool right_ok = (p[nl] < 'A' || p[nl] > 'Z');
+ if (left_ok && right_ok)
+ return true;
+ p += (nl > 0 ? nl : 1);
+ }
+ return false;
+}
+
+void goal_parse_verdict(const char *reply, goal_verdict *out)
+{
+ const char *src = reply ? reply : "";
+ char *upper = xstrdup(src);
+ for (char *c = upper; *c; c++)
+ if (*c >= 'a' && *c <= 'z')
+ *c = (char)(*c - 'a' + 'A');
+ out->met = has_word(upper, "MET") && !has_word(upper, "NOT MET") &&
+ !has_word(upper, "UNMET");
+ free(upper);
+
+ const char *chosen = NULL;
+ size_t chosen_len = 0;
+ const char *p = src;
+ while (*p) {
+ const char *nl = strchr(p, '\n');
+ size_t linelen = nl ? (size_t)(nl - p) : strlen(p);
+ size_t tlen;
+ const char *line = xtrim_span(p, linelen, &tlen);
+ if (tlen > 0 && !str_eq_i(line, tlen, "MET") &&
+ !str_eq_i(line, tlen, "NOT MET")) {
+ chosen = line;
+ chosen_len = tlen;
+ break;
+ }
+ if (!nl)
+ break;
+ p = nl + 1;
+ }
+ out->reason = chosen
+ ? xstrndup(chosen, chosen_len)
+ : xstrdup("the reviewer did not give a reason");
+}
diff --git a/src/goal.h b/src/goal.h
new file mode 100644
index 0000000..6ef823d
--- /dev/null
+++ b/src/goal.h
@@ -0,0 +1,53 @@
+#ifndef LLSH_GOAL_H
+#define LLSH_GOAL_H
+
+#include "util.h"
+
+/* Pure helpers for the /goal goal-based loop (mirrors goal.rs): parse the
+ * command, build each attempt's prompt, parse the evaluator's verdict.
+ * Side-effect free; the evaluator's model call lives in the session and the
+ * iteration loop in main. */
+
+#define GOAL_DEFAULT_MAX_TRIES 5UL
+
+/* Parsed /goal command: success criteria + attempt cap. */
+typedef struct {
+ char *criteria; /* owned */
+ unsigned long max_tries;
+} goal_spec;
+
+/* Evaluator's judgment of a single attempt. reason is owned. */
+typedef struct {
+ bool met;
+ char *reason;
+} goal_verdict;
+
+/* Summary of a finished /goal run. criteria is owned. */
+typedef struct {
+ char *criteria;
+ unsigned long max_tries;
+ unsigned long turns_done;
+ unsigned long long tokens_used;
+ bool met;
+} goal_report;
+
+void goal_spec_free(goal_spec *s);
+void goal_report_free(goal_report *r);
+
+/* System preamble for the tool-less goal evaluator. */
+const char *goal_evaluator_preamble(void);
+
+/* Parse "<criteria>[, stop after <N> tries]".
+ * Returns 1 on success (fills *out), 0 on failure (*err set, caller frees). */
+int goal_parse(const char *input, goal_spec *out, char **err);
+
+/* Prompt for a goal attempt (attempt starts at 1; feedback may be NULL).
+ * Caller frees. */
+char *goal_prompt(const goal_spec *spec, unsigned long attempt,
+ const char *feedback);
+
+/* Parse the evaluator's reply into a verdict (conservative: met only when
+ * MET appears and NOT MET does not). Caller frees out->reason. */
+void goal_parse_verdict(const char *reply, goal_verdict *out);
+
+#endif
diff --git a/src/input.cpp b/src/input.cpp
new file mode 100644
index 0000000..935f998
--- /dev/null
+++ b/src/input.cpp
@@ -0,0 +1,1949 @@
+#include "input.h"
+
+#include <ctype.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <stdint.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include <poll.h>
+#include <sys/stat.h>
+#include <termios.h>
+
+#include <cjson/cJSON.h>
+
+/* ================================================================== */
+/* Actions / events */
+/* ================================================================== */
+
+void input_action_free(input_action *a) {
+ if (!a) return;
+ free(a->text);
+ a->text = NULL;
+ a->kind = ACT_NONE;
+}
+
+void evlist_init(evlist *l) {
+ memset(l, 0, sizeof(*l));
+}
+
+void evlist_push(evlist *l, input_key k, const unsigned char *raw, size_t raw_len) {
+ if (l->len == l->cap) {
+ size_t new_cap = l->cap ? l->cap * 2 : 8;
+ input_key *ni = (input_key *) xmalloc(new_cap * sizeof(*ni));
+ unsigned char* *nr = (unsigned char* * ) xmalloc(new_cap * sizeof(*nr));
+ size_t *nl = (size_t *) xmalloc(new_cap * sizeof(*nl));
+ if (l->cap > 0) {
+ for (size_t i = 0; i < l->len; i++) {
+ size_t src = (l->head + i) % l->cap;
+ ni[i] = l->items[src];
+ nr[i] = l->raws[src];
+ nl[i] = l->lens[src];
+ }
+ free(l->items);
+ free(l->raws);
+ free(l->lens);
+ }
+ l->items = ni;
+ l->raws = nr;
+ l->lens = nl;
+ l->head = 0;
+ l->cap = new_cap;
+ }
+ size_t i = (l->head + l->len) % l->cap;
+ l->items[i] = k;
+ l->raws[i] = (unsigned char* ) xmalloc(raw_len + 1);
+ memcpy(l->raws[i], raw, raw_len);
+ l->raws[i][raw_len] = '\0';
+ l->lens[i] = raw_len;
+ l->len++;
+}
+
+void evlist_pop(evlist *l) {
+ if (l->len == 0) return;
+ free(l->raws[l->head]);
+ l->head = (l->head + 1 == l->cap) ? 0 : l->head + 1;
+ l->len--;
+}
+
+void evlist_free(evlist *l) {
+ if (l->cap > 0)
+ for (size_t i = 0; i < l->len; i++)
+ free(l->raws[(l->head + i) % l->cap]);
+ free(l->items);
+ free(l->raws);
+ free(l->lens);
+ memset(l, 0, sizeof(*l));
+}
+
+/* ================================================================== */
+/* Display width */
+/* ================================================================== */
+
+/* Unicode width tables generated from `unicode-width` 0.2 (the oracle's
+ * source of truth), covering U+0000..U+2FFFF. Zero-width (control chars,
+ * combining marks) and wide/fullwidth codepoint ranges. */
+static const uint32_t zw[][2] = {
+ {0x0u, 0x1fu},
+ {0x7fu, 0x9fu},
+ {0xadu, 0xadu},
+ {0x300u, 0x36fu},
+ {0x483u, 0x489u},
+ {0x591u, 0x5bdu},
+ {0x5bfu, 0x5bfu},
+ {0x5c1u, 0x5c2u},
+ {0x5c4u, 0x5c5u},
+ {0x5c7u, 0x5c7u},
+ {0x605u, 0x605u},
+ {0x610u, 0x61au},
+ {0x61cu, 0x61cu},
+ {0x64bu, 0x65fu},
+ {0x670u, 0x670u},
+ {0x6d6u, 0x6dcu},
+ {0x6dfu, 0x6e4u},
+ {0x6e7u, 0x6e8u},
+ {0x6eau, 0x6edu},
+ {0x70fu, 0x70fu},
+ {0x711u, 0x711u},
+ {0x730u, 0x74au},
+ {0x7a6u, 0x7b0u},
+ {0x7ebu, 0x7f3u},
+ {0x7fdu, 0x7fdu},
+ {0x816u, 0x819u},
+ {0x81bu, 0x823u},
+ {0x825u, 0x827u},
+ {0x829u, 0x82du},
+ {0x859u, 0x85bu},
+ {0x890u, 0x891u},
+ {0x897u, 0x89fu},
+ {0x8cau, 0x902u},
+ {0x93au, 0x93au},
+ {0x93cu, 0x93cu},
+ {0x941u, 0x948u},
+ {0x94du, 0x94du},
+ {0x951u, 0x957u},
+ {0x962u, 0x963u},
+ {0x981u, 0x981u},
+ {0x9bcu, 0x9bcu},
+ {0x9beu, 0x9beu},
+ {0x9c1u, 0x9c4u},
+ {0x9cdu, 0x9cdu},
+ {0x9d7u, 0x9d7u},
+ {0x9e2u, 0x9e3u},
+ {0x9feu, 0x9feu},
+ {0xa01u, 0xa02u},
+ {0xa3cu, 0xa3cu},
+ {0xa41u, 0xa42u},
+ {0xa47u, 0xa48u},
+ {0xa4bu, 0xa4du},
+ {0xa51u, 0xa51u},
+ {0xa70u, 0xa71u},
+ {0xa75u, 0xa75u},
+ {0xa81u, 0xa82u},
+ {0xabcu, 0xabcu},
+ {0xac1u, 0xac5u},
+ {0xac7u, 0xac8u},
+ {0xacdu, 0xacdu},
+ {0xae2u, 0xae3u},
+ {0xafau, 0xaffu},
+ {0xb01u, 0xb01u},
+ {0xb3cu, 0xb3cu},
+ {0xb3eu, 0xb3fu},
+ {0xb41u, 0xb44u},
+ {0xb4du, 0xb4du},
+ {0xb55u, 0xb57u},
+ {0xb62u, 0xb63u},
+ {0xb82u, 0xb82u},
+ {0xbbeu, 0xbbeu},
+ {0xbc0u, 0xbc0u},
+ {0xbcdu, 0xbcdu},
+ {0xbd7u, 0xbd7u},
+ {0xc00u, 0xc00u},
+ {0xc04u, 0xc04u},
+ {0xc3cu, 0xc3cu},
+ {0xc3eu, 0xc40u},
+ {0xc46u, 0xc48u},
+ {0xc4au, 0xc4du},
+ {0xc55u, 0xc56u},
+ {0xc62u, 0xc63u},
+ {0xc81u, 0xc81u},
+ {0xcbcu, 0xcbcu},
+ {0xcbfu, 0xcc0u},
+ {0xcc2u, 0xcc2u},
+ {0xcc6u, 0xcc8u},
+ {0xccau, 0xccdu},
+ {0xcd5u, 0xcd6u},
+ {0xce2u, 0xce3u},
+ {0xd00u, 0xd01u},
+ {0xd3bu, 0xd3cu},
+ {0xd3eu, 0xd3eu},
+ {0xd41u, 0xd44u},
+ {0xd4du, 0xd4eu},
+ {0xd57u, 0xd57u},
+ {0xd62u, 0xd63u},
+ {0xd81u, 0xd81u},
+ {0xdcau, 0xdcau},
+ {0xdcfu, 0xdcfu},
+ {0xdd2u, 0xdd4u},
+ {0xdd6u, 0xdd6u},
+ {0xddfu, 0xddfu},
+ {0xe31u, 0xe31u},
+ {0xe34u, 0xe3au},
+ {0xe47u, 0xe4eu},
+ {0xeb1u, 0xeb1u},
+ {0xeb4u, 0xebcu},
+ {0xec8u, 0xeceu},
+ {0xf18u, 0xf19u},
+ {0xf35u, 0xf35u},
+ {0xf37u, 0xf37u},
+ {0xf39u, 0xf39u},
+ {0xf71u, 0xf7eu},
+ {0xf80u, 0xf84u},
+ {0xf86u, 0xf87u},
+ {0xf8du, 0xf97u},
+ {0xf99u, 0xfbcu},
+ {0xfc6u, 0xfc6u},
+ {0x102du, 0x1030u},
+ {0x1032u, 0x1037u},
+ {0x1039u, 0x103au},
+ {0x103du, 0x103eu},
+ {0x1058u, 0x1059u},
+ {0x105eu, 0x1060u},
+ {0x1071u, 0x1074u},
+ {0x1082u, 0x1082u},
+ {0x1085u, 0x1086u},
+ {0x108du, 0x108du},
+ {0x109du, 0x109du},
+ {0x1160u, 0x11ffu},
+ {0x135du, 0x135fu},
+ {0x1712u, 0x1715u},
+ {0x1732u, 0x1734u},
+ {0x1752u, 0x1753u},
+ {0x1772u, 0x1773u},
+ {0x17b4u, 0x17b5u},
+ {0x17b7u, 0x17bdu},
+ {0x17c6u, 0x17c6u},
+ {0x17c9u, 0x17d3u},
+ {0x17ddu, 0x17ddu},
+ {0x180bu, 0x180fu},
+ {0x1885u, 0x1886u},
+ {0x18a9u, 0x18a9u},
+ {0x1920u, 0x1922u},
+ {0x1927u, 0x1928u},
+ {0x1932u, 0x1932u},
+ {0x1939u, 0x193bu},
+ {0x1a17u, 0x1a18u},
+ {0x1a1bu, 0x1a1bu},
+ {0x1a56u, 0x1a56u},
+ {0x1a58u, 0x1a5eu},
+ {0x1a60u, 0x1a60u},
+ {0x1a62u, 0x1a62u},
+ {0x1a65u, 0x1a6cu},
+ {0x1a73u, 0x1a7cu},
+ {0x1a7fu, 0x1a7fu},
+ {0x1ab0u, 0x1addu},
+ {0x1ae0u, 0x1aebu},
+ {0x1b00u, 0x1b03u},
+ {0x1b34u, 0x1b3du},
+ {0x1b42u, 0x1b44u},
+ {0x1b6bu, 0x1b73u},
+ {0x1b80u, 0x1b81u},
+ {0x1ba2u, 0x1ba5u},
+ {0x1ba8u, 0x1badu},
+ {0x1be6u, 0x1be6u},
+ {0x1be8u, 0x1be9u},
+ {0x1bedu, 0x1bedu},
+ {0x1befu, 0x1bf3u},
+ {0x1c2cu, 0x1c33u},
+ {0x1c36u, 0x1c37u},
+ {0x1cd0u, 0x1cd2u},
+ {0x1cd4u, 0x1ce0u},
+ {0x1ce2u, 0x1ce8u},
+ {0x1cedu, 0x1cedu},
+ {0x1cf4u, 0x1cf4u},
+ {0x1cf8u, 0x1cf9u},
+ {0x1dc0u, 0x1dffu},
+ {0x200bu, 0x200fu},
+ {0x202au, 0x202eu},
+ {0x2060u, 0x206fu},
+ {0x20d0u, 0x20f0u},
+ {0x2cefu, 0x2cf1u},
+ {0x2de0u, 0x2dffu},
+ {0x302au, 0x302fu},
+ {0x3099u, 0x309au},
+ {0x3164u, 0x3164u},
+ {0xa66fu, 0xa672u},
+ {0xa674u, 0xa67du},
+ {0xa69eu, 0xa69fu},
+ {0xa6f0u, 0xa6f1u},
+ {0xa802u, 0xa802u},
+ {0xa806u, 0xa806u},
+ {0xa80bu, 0xa80bu},
+ {0xa825u, 0xa826u},
+ {0xa82cu, 0xa82cu},
+ {0xa8c4u, 0xa8c5u},
+ {0xa8e0u, 0xa8f1u},
+ {0xa8fau, 0xa8fau},
+ {0xa8ffu, 0xa8ffu},
+ {0xa926u, 0xa92du},
+ {0xa947u, 0xa951u},
+ {0xa953u, 0xa953u},
+ {0xa980u, 0xa982u},
+ {0xa9b3u, 0xa9b3u},
+ {0xa9b6u, 0xa9b9u},
+ {0xa9bcu, 0xa9bdu},
+ {0xa9c0u, 0xa9c0u},
+ {0xa9e5u, 0xa9e5u},
+ {0xaa29u, 0xaa2eu},
+ {0xaa31u, 0xaa32u},
+ {0xaa35u, 0xaa36u},
+ {0xaa43u, 0xaa43u},
+ {0xaa4cu, 0xaa4cu},
+ {0xaa7cu, 0xaa7cu},
+ {0xaab0u, 0xaab0u},
+ {0xaab2u, 0xaab4u},
+ {0xaab7u, 0xaab8u},
+ {0xaabeu, 0xaabfu},
+ {0xaac1u, 0xaac1u},
+ {0xaaecu, 0xaaedu},
+ {0xaaf6u, 0xaaf6u},
+ {0xabe5u, 0xabe5u},
+ {0xabe8u, 0xabe8u},
+ {0xabedu, 0xabedu},
+ {0xd7b0u, 0xd7c6u},
+ {0xd7cbu, 0xd7fbu},
+ {0xfb1eu, 0xfb1eu},
+ {0xfe00u, 0xfe0fu},
+ {0xfe20u, 0xfe2fu},
+ {0xfeffu, 0xfeffu},
+ {0xff9eu, 0xffa0u},
+ {0xfff0u, 0xfff8u},
+ {0x101fdu, 0x101fdu},
+ {0x102e0u, 0x102e0u},
+ {0x10376u, 0x1037au},
+ {0x10a01u, 0x10a03u},
+ {0x10a05u, 0x10a06u},
+ {0x10a0cu, 0x10a0fu},
+ {0x10a38u, 0x10a3au},
+ {0x10a3fu, 0x10a3fu},
+ {0x10ae5u, 0x10ae6u},
+ {0x10d24u, 0x10d27u},
+ {0x10d69u, 0x10d6du},
+ {0x10eabu, 0x10eacu},
+ {0x10efau, 0x10effu},
+ {0x10f46u, 0x10f50u},
+ {0x10f82u, 0x10f85u},
+ {0x11001u, 0x11001u},
+ {0x11038u, 0x11046u},
+ {0x11070u, 0x11070u},
+ {0x11073u, 0x11074u},
+ {0x1107fu, 0x11081u},
+ {0x110b3u, 0x110b6u},
+ {0x110b9u, 0x110bau},
+ {0x110c2u, 0x110c2u},
+ {0x11100u, 0x11102u},
+ {0x11127u, 0x1112bu},
+ {0x1112du, 0x11134u},
+ {0x11173u, 0x11173u},
+ {0x11180u, 0x11181u},
+ {0x111b6u, 0x111beu},
+ {0x111c0u, 0x111c0u},
+ {0x111c2u, 0x111c3u},
+ {0x111c9u, 0x111ccu},
+ {0x111cfu, 0x111cfu},
+ {0x1122fu, 0x11231u},
+ {0x11234u, 0x11237u},
+ {0x1123eu, 0x1123eu},
+ {0x11241u, 0x11241u},
+ {0x112dfu, 0x112dfu},
+ {0x112e3u, 0x112eau},
+ {0x11300u, 0x11301u},
+ {0x1133bu, 0x1133cu},
+ {0x1133eu, 0x1133eu},
+ {0x11340u, 0x11340u},
+ {0x1134du, 0x1134du},
+ {0x11357u, 0x11357u},
+ {0x11366u, 0x1136cu},
+ {0x11370u, 0x11374u},
+ {0x113b8u, 0x113b8u},
+ {0x113bbu, 0x113c0u},
+ {0x113c2u, 0x113c2u},
+ {0x113c5u, 0x113c5u},
+ {0x113c7u, 0x113c9u},
+ {0x113ceu, 0x113d2u},
+ {0x113e1u, 0x113e2u},
+ {0x11438u, 0x1143fu},
+ {0x11442u, 0x11444u},
+ {0x11446u, 0x11446u},
+ {0x1145eu, 0x1145eu},
+ {0x114b0u, 0x114b0u},
+ {0x114b3u, 0x114b8u},
+ {0x114bau, 0x114bau},
+ {0x114bdu, 0x114bdu},
+ {0x114bfu, 0x114c0u},
+ {0x114c2u, 0x114c3u},
+ {0x115afu, 0x115afu},
+ {0x115b2u, 0x115b5u},
+ {0x115bcu, 0x115bdu},
+ {0x115bfu, 0x115c0u},
+ {0x115dcu, 0x115ddu},
+ {0x11633u, 0x1163au},
+ {0x1163du, 0x1163du},
+ {0x1163fu, 0x11640u},
+ {0x116abu, 0x116abu},
+ {0x116adu, 0x116adu},
+ {0x116b0u, 0x116b7u},
+ {0x1171du, 0x1171du},
+ {0x1171fu, 0x1171fu},
+ {0x11722u, 0x11725u},
+ {0x11727u, 0x1172bu},
+ {0x1182fu, 0x11837u},
+ {0x11839u, 0x1183au},
+ {0x11930u, 0x11930u},
+ {0x1193bu, 0x1193fu},
+ {0x11941u, 0x11941u},
+ {0x11943u, 0x11943u},
+ {0x119d4u, 0x119d7u},
+ {0x119dau, 0x119dbu},
+ {0x119e0u, 0x119e0u},
+ {0x11a01u, 0x11a0au},
+ {0x11a33u, 0x11a38u},
+ {0x11a3bu, 0x11a3eu},
+ {0x11a47u, 0x11a47u},
+ {0x11a51u, 0x11a56u},
+ {0x11a59u, 0x11a5bu},
+ {0x11a84u, 0x11a96u},
+ {0x11a98u, 0x11a99u},
+ {0x11b60u, 0x11b60u},
+ {0x11b62u, 0x11b64u},
+ {0x11b66u, 0x11b66u},
+ {0x11c30u, 0x11c36u},
+ {0x11c38u, 0x11c3du},
+ {0x11c3fu, 0x11c3fu},
+ {0x11c92u, 0x11ca7u},
+ {0x11caau, 0x11cb0u},
+ {0x11cb2u, 0x11cb3u},
+ {0x11cb5u, 0x11cb6u},
+ {0x11d31u, 0x11d36u},
+ {0x11d3au, 0x11d3au},
+ {0x11d3cu, 0x11d3du},
+ {0x11d3fu, 0x11d47u},
+ {0x11d90u, 0x11d91u},
+ {0x11d95u, 0x11d95u},
+ {0x11d97u, 0x11d97u},
+ {0x11ef3u, 0x11ef4u},
+ {0x11f00u, 0x11f02u},
+ {0x11f36u, 0x11f3au},
+ {0x11f40u, 0x11f42u},
+ {0x11f5au, 0x11f5au},
+ {0x13440u, 0x13440u},
+ {0x13447u, 0x13455u},
+ {0x1611eu, 0x16129u},
+ {0x1612du, 0x1612fu},
+ {0x16af0u, 0x16af4u},
+ {0x16b30u, 0x16b36u},
+ {0x16f4fu, 0x16f4fu},
+ {0x16f8fu, 0x16f92u},
+ {0x16fe4u, 0x16fe4u},
+ {0x16ff0u, 0x16ff1u},
+ {0x1bc9du, 0x1bc9eu},
+ {0x1bca0u, 0x1bca3u},
+ {0x1cf00u, 0x1cf2du},
+ {0x1cf30u, 0x1cf46u},
+ {0x1d165u, 0x1d169u},
+ {0x1d16du, 0x1d182u},
+ {0x1d185u, 0x1d18bu},
+ {0x1d1aau, 0x1d1adu},
+ {0x1d242u, 0x1d244u},
+ {0x1da00u, 0x1da36u},
+ {0x1da3bu, 0x1da6cu},
+ {0x1da75u, 0x1da75u},
+ {0x1da84u, 0x1da84u},
+ {0x1da9bu, 0x1da9fu},
+ {0x1daa1u, 0x1daafu},
+ {0x1e000u, 0x1e006u},
+ {0x1e008u, 0x1e018u},
+ {0x1e01bu, 0x1e021u},
+ {0x1e023u, 0x1e024u},
+ {0x1e026u, 0x1e02au},
+ {0x1e08fu, 0x1e08fu},
+ {0x1e130u, 0x1e136u},
+ {0x1e2aeu, 0x1e2aeu},
+ {0x1e2ecu, 0x1e2efu},
+ {0x1e4ecu, 0x1e4efu},
+ {0x1e5eeu, 0x1e5efu},
+ {0x1e6e3u, 0x1e6e3u},
+ {0x1e6e6u, 0x1e6e6u},
+ {0x1e6eeu, 0x1e6efu},
+ {0x1e6f5u, 0x1e6f5u},
+ {0x1e8d0u, 0x1e8d6u},
+ {0x1e944u, 0x1e94au},
+};
+static const size_t zw_n = 394;
+
+static const uint32_t wd[][2] = {
+ {0x1100u, 0x115fu},
+ {0x17a4u, 0x17a4u},
+ {0x231au, 0x231bu},
+ {0x2329u, 0x232au},
+ {0x23e9u, 0x23ecu},
+ {0x23f0u, 0x23f0u},
+ {0x23f3u, 0x23f3u},
+ {0x25fdu, 0x25feu},
+ {0x2614u, 0x2615u},
+ {0x2630u, 0x2637u},
+ {0x2648u, 0x2653u},
+ {0x267fu, 0x267fu},
+ {0x268au, 0x268fu},
+ {0x2693u, 0x2693u},
+ {0x26a1u, 0x26a1u},
+ {0x26aau, 0x26abu},
+ {0x26bdu, 0x26beu},
+ {0x26c4u, 0x26c5u},
+ {0x26ceu, 0x26ceu},
+ {0x26d4u, 0x26d4u},
+ {0x26eau, 0x26eau},
+ {0x26f2u, 0x26f3u},
+ {0x26f5u, 0x26f5u},
+ {0x26fau, 0x26fau},
+ {0x26fdu, 0x26fdu},
+ {0x2705u, 0x2705u},
+ {0x270au, 0x270bu},
+ {0x2728u, 0x2728u},
+ {0x274cu, 0x274cu},
+ {0x274eu, 0x274eu},
+ {0x2753u, 0x2755u},
+ {0x2757u, 0x2757u},
+ {0x2795u, 0x2797u},
+ {0x27b0u, 0x27b0u},
+ {0x27bfu, 0x27bfu},
+ {0x2b1bu, 0x2b1cu},
+ {0x2b50u, 0x2b50u},
+ {0x2b55u, 0x2b55u},
+ {0x2e80u, 0x2e99u},
+ {0x2e9bu, 0x2ef3u},
+ {0x2f00u, 0x2fd5u},
+ {0x2ff0u, 0x3029u},
+ {0x3030u, 0x303eu},
+ {0x3041u, 0x3096u},
+ {0x309bu, 0x30ffu},
+ {0x3105u, 0x312fu},
+ {0x3131u, 0x3163u},
+ {0x3165u, 0x318eu},
+ {0x3190u, 0x31e5u},
+ {0x31efu, 0x321eu},
+ {0x3220u, 0x3247u},
+ {0x3250u, 0xa48cu},
+ {0xa490u, 0xa4c6u},
+ {0xa960u, 0xa97cu},
+ {0xac00u, 0xd7a3u},
+ {0xf900u, 0xfaffu},
+ {0xfe10u, 0xfe19u},
+ {0xfe30u, 0xfe52u},
+ {0xfe54u, 0xfe66u},
+ {0xfe68u, 0xfe6bu},
+ {0xff01u, 0xff60u},
+ {0xffe0u, 0xffe6u},
+ {0x16fe0u, 0x16fe3u},
+ {0x16ff2u, 0x16ff6u},
+ {0x17000u, 0x18cd5u},
+ {0x18cffu, 0x18d1eu},
+ {0x18d80u, 0x18df2u},
+ {0x1aff0u, 0x1aff3u},
+ {0x1aff5u, 0x1affbu},
+ {0x1affdu, 0x1affeu},
+ {0x1b000u, 0x1b122u},
+ {0x1b132u, 0x1b132u},
+ {0x1b150u, 0x1b152u},
+ {0x1b155u, 0x1b155u},
+ {0x1b164u, 0x1b167u},
+ {0x1b170u, 0x1b2fbu},
+ {0x1d300u, 0x1d356u},
+ {0x1d360u, 0x1d376u},
+ {0x1f004u, 0x1f004u},
+ {0x1f0cfu, 0x1f0cfu},
+ {0x1f18eu, 0x1f18eu},
+ {0x1f191u, 0x1f19au},
+ {0x1f200u, 0x1f202u},
+ {0x1f210u, 0x1f23bu},
+ {0x1f240u, 0x1f248u},
+ {0x1f250u, 0x1f251u},
+ {0x1f260u, 0x1f265u},
+ {0x1f300u, 0x1f320u},
+ {0x1f32du, 0x1f335u},
+ {0x1f337u, 0x1f37cu},
+ {0x1f37eu, 0x1f393u},
+ {0x1f3a0u, 0x1f3cau},
+ {0x1f3cfu, 0x1f3d3u},
+ {0x1f3e0u, 0x1f3f0u},
+ {0x1f3f4u, 0x1f3f4u},
+ {0x1f3f8u, 0x1f43eu},
+ {0x1f440u, 0x1f440u},
+ {0x1f442u, 0x1f4fcu},
+ {0x1f4ffu, 0x1f53du},
+ {0x1f54bu, 0x1f54eu},
+ {0x1f550u, 0x1f567u},
+ {0x1f57au, 0x1f57au},
+ {0x1f595u, 0x1f596u},
+ {0x1f5a4u, 0x1f5a4u},
+ {0x1f5fbu, 0x1f64fu},
+ {0x1f680u, 0x1f6c5u},
+ {0x1f6ccu, 0x1f6ccu},
+ {0x1f6d0u, 0x1f6d2u},
+ {0x1f6d5u, 0x1f6d8u},
+ {0x1f6dcu, 0x1f6dfu},
+ {0x1f6ebu, 0x1f6ecu},
+ {0x1f6f4u, 0x1f6fcu},
+ {0x1f7e0u, 0x1f7ebu},
+ {0x1f7f0u, 0x1f7f0u},
+ {0x1f90cu, 0x1f93au},
+ {0x1f93cu, 0x1f945u},
+ {0x1f947u, 0x1f9ffu},
+ {0x1fa70u, 0x1fa7cu},
+ {0x1fa80u, 0x1fa8au},
+ {0x1fa8eu, 0x1fac6u},
+ {0x1fac8u, 0x1fac8u},
+ {0x1facdu, 0x1fadcu},
+ {0x1fadfu, 0x1faeau},
+ {0x1faefu, 0x1faf8u},
+ {0x20000u, 0x2fffdu},
+};
+static const size_t wd_n = 125;
+
+static int range_has(const uint32_t t[][2], size_t n, uint32_t cp) {
+ size_t lo = 0, hi = n;
+ while (lo < hi) {
+ size_t mid = lo + (hi - lo) / 2;
+ if (t[mid][0] > cp) hi = mid;
+ else if (t[mid][1] < cp) lo = mid + 1;
+ else return 1;
+ }
+ return 0;
+}
+
+static int charw(size_t cp) {
+ if (cp < 0x20 || (cp >= 0x7F && cp <= 0x9F)) return 0; /* C0, DEL, C1 */
+ if (range_has(zw, zw_n, (uint32_t)cp)) return 0;
+ if (range_has(wd, wd_n, (uint32_t)cp)) return 2;
+ return 1;
+}
+
+size_t input_display_len_n(const char *s, size_t n) {
+ size_t width = 0;
+ const char *p = s;
+ const char *end = s + n;
+ while (p < end) {
+ size_t cp;
+ const char *nxt = utf8_next(p, end, &cp);
+ if (cp == 0x1B) {
+ if (nxt >= end) break; /* lone trailing ESC: not rendered */
+ unsigned char second = (unsigned char)*nxt;
+ if (second == '[') {
+ const char *q = nxt + 1;
+ while (q < end) {
+ size_t c2;
+ const char *q2 = utf8_next(q, end, &c2);
+ if (c2 >= 0x40 && c2 <= 0x7E) break;
+ q = q2;
+ }
+ if (q >= end) return width;
+ size_t c3;
+ p = utf8_next(q, end, &c3);
+ continue;
+ }
+ if (second == ']' || second == 'P' || second == '^' || second == 'X' ||
+ second == '_') {
+ /* OSC / DCS / SOS / PM / APC: scan to BEL or ESC \ */
+ int prev_esc = 0;
+ const char *q = nxt + 1;
+ while (q < end) {
+ unsigned char c = (unsigned char)*q;
+ if (c == 0x07 || (prev_esc && c == '\\')) break;
+ prev_esc = (c == 0x1B);
+ q++;
+ }
+ if (q >= end) return width;
+ p = q + 1;
+ continue;
+ }
+ if (second == 'O') {
+ /* SS3: `O` plus one more final byte (e.g. ESC O A). */
+ if (nxt + 1 >= end) return width;
+ size_t c3;
+ p = utf8_next(nxt + 1, end, &c3);
+ continue;
+ }
+ if (second >= 0x20 && second <= 0x7E) {
+ /* Intermediates 0x20..=0x2F then one final byte; a bare
+ * final byte ends the sequence on the spot. */
+ const char *q = nxt + 1;
+ while (q < end) {
+ unsigned char c = (unsigned char)*q;
+ if (c < 0x20 || c > 0x2F) break;
+ q++;
+ }
+ if (q >= end) return width;
+ size_t c3;
+ p = utf8_next(q, end, &c3);
+ continue;
+ }
+ p = nxt; /* other: lone ESC */
+ continue;
+ }
+ if (cp != 0x7F) width += (size_t)charw(cp);
+ p = nxt;
+ }
+ return width;
+}
+
+size_t input_display_len(const char *s) {
+ return input_display_len_n(s, strlen(s));
+}
+
+/* ================================================================== */
+/* Parser */
+/* ================================================================== */
+
+void key_parser_init(key_parser *p) {
+ p->state = KST_GROUND;
+ xbuf_init(&p->seq);
+ xbuf_init(&p->csi_params);
+ p->utf8_len = 0;
+ p->pasting = false;
+}
+
+void key_parser_free(key_parser *p) {
+ xbuf_free(&p->seq);
+ xbuf_free(&p->csi_params);
+ p->state = KST_GROUND;
+ p->utf8_len = 0;
+ p->pasting = false;
+}
+
+static void kp_emit(key_parser *p, evlist *out, int kind, unsigned int cp) {
+ evlist_push(out, (input_key){kind, cp}, (const unsigned char *)p->seq.data,
+ p->seq.len);
+ xbuf_reset(&p->seq);
+ xbuf_reset(&p->csi_params);
+}
+
+static int kp_digit(unsigned char b) {
+ return b >= '0' && b <= '9';
+}
+
+static int first_num(const char *s, unsigned long *out) {
+ unsigned long d = 0;
+ int any = 0;
+ for (const char *p = s; *p >= '0' && *p <= '9'; p++) {
+ if (any) d = d * 10 + (unsigned long)(*p - '0');
+ else
+ d = (unsigned long)(*p - '0');
+ any = 1;
+ if (d > 0xFFFFFFFFUL) break;
+ }
+ if (!any) return 0;
+ *out = d;
+ return 1;
+}
+
+static int csi_u(const char *params, int *key) {
+ const char *semi = strchr(params, ';');
+ size_t first_len = semi ? (size_t)(semi - params) : strlen(params);
+ if (first_len == 0 || first_len > 10) return 0;
+ unsigned long cp = 0;
+ for (size_t i = 0; i < first_len; i++) {
+ cp = cp * 10 + (unsigned long)(params[i] - '0');
+ if (cp > 0xFFFFFFFFUL) return 0;
+ }
+ unsigned long mods = 1;
+ if (semi) {
+ const char *m = semi + 1;
+ size_t mlen = strlen(m);
+ if (mlen > 0 && mlen <= 10) {
+ unsigned long v = 0;
+ int ok = 1;
+ for (size_t i = 0; i < mlen; i++) {
+ if (m[i] < '0' || m[i] > '9') {
+ ok = 0;
+ break;
+ }
+ v = v * 10 + (unsigned long)(m[i] - '0');
+ if (v > 0xFFFFFFFFUL) {
+ ok = 0;
+ break;
+ }
+ }
+ if (ok) mods = v;
+ }
+ }
+ int shift = ((mods - 1UL) & 1UL) == 1UL;
+ int k;
+ switch (cp) {
+ case 9u:
+ k = shift ? KEY_BACKTAB : KEY_TAB;
+ break;
+ case 12u:
+ k = KEY_BACKSPACE;
+ break;
+ case 13u:
+ k = KEY_ENTER;
+ break;
+ case 27u:
+ k = KEY_ESC;
+ break;
+ case 127u:
+ k = KEY_DELETE;
+ break;
+ case 0x2190u:
+ k = KEY_LEFT;
+ break;
+ case 0x2191u:
+ k = KEY_UP;
+ break;
+ case 0x2192u:
+ k = KEY_RIGHT;
+ break;
+ case 0x2193u:
+ k = KEY_DOWN;
+ break;
+ default:
+ return 0;
+ }
+ *key = k;
+ return 1;
+}
+
+static int kp_ground(key_parser *p, unsigned char b, evlist *out);
+
+static size_t utf8_lead_len(unsigned char lead) {
+ if (lead >= 0xF0 && lead < 0xF8) return 4;
+ if (lead >= 0xE0) return 3;
+ if (lead >= 0xC0) return 2;
+ return 1;
+}
+
+static int kp_utf8_step(key_parser *p, unsigned char b, evlist *out) {
+ if (p->utf8_len == 0) {
+ if (b < 0xC0 || b >= 0xF8) {
+ p->utf8_len = 0;
+ xbuf_reset(&p->seq);
+ xbuf_push(&p->seq, (char)b);
+ kp_emit(p, out, KEY_UNKNOWN, 0);
+ return 1;
+ }
+ p->utf8[p->utf8_len++] = b;
+ return 0;
+ }
+ if (b >= 0x80 && b < 0xC0) {
+ if (p->utf8_len < 8) p->utf8[p->utf8_len++] = b;
+ size_t expected = utf8_lead_len(p->utf8[0]);
+ if (p->utf8_len < expected) return 0;
+ if (utf8_is_valid((const char *)p->utf8, p->utf8_len)) {
+ size_t cp = 0;
+ utf8_next((const char *)p->utf8, (const char *)p->utf8 + p->utf8_len,
+ &cp);
+ unsigned char tmp[8];
+ size_t n = p->utf8_len;
+ memcpy(tmp, p->utf8, n);
+ p->utf8_len = 0;
+ xbuf_reset(&p->seq);
+ for (size_t i = 0; i < n; i++) xbuf_push(&p->seq, (char)tmp[i]);
+ kp_emit(p, out, KEY_CHAR, cp);
+ return 1;
+ }
+ p->utf8_len = 0;
+ return 0;
+ }
+ p->utf8_len = 0;
+ return kp_ground(p, b, out);
+}
+
+static int kp_ground(key_parser *p, unsigned char b, evlist *out) {
+ if (b == 0x1B) {
+ p->state = KST_ESCAPE;
+ xbuf_reset(&p->seq);
+ xbuf_push(&p->seq, (char)b);
+ return 0;
+ }
+ if (b == 0x09) {
+ xbuf_reset(&p->seq);
+ xbuf_push(&p->seq, (char)b);
+ kp_emit(p, out, KEY_TAB, 0);
+ return 1;
+ }
+ if (b == 0x0D || b == 0x0A) {
+ /* CR (0x0D) submits. During bracketed paste every CR/LF is a newline,
+ * preserving multi-line pastes. LF (0x0A, Ctrl+J) outside of paste is
+ * a dead key. Multi-line typing is done with backslash+Enter. */
+ if (p->pasting) {
+ xbuf_reset(&p->seq);
+ xbuf_push(&p->seq, (char)b);
+ kp_emit(p, out, KEY_NEWLINE, 0);
+ return 1;
+ }
+ if (b == 0x0D) {
+ xbuf_reset(&p->seq);
+ xbuf_push(&p->seq, (char)b);
+ kp_emit(p, out, KEY_ENTER, 0);
+ return 1;
+ }
+ xbuf_reset(&p->seq); /* discard LF: dead key, no event */
+ return 0;
+ }
+ if (b == 0x7F || b == 0x08) {
+ xbuf_reset(&p->seq);
+ xbuf_push(&p->seq, (char)b);
+ kp_emit(p, out, KEY_BACKSPACE, 0);
+ return 1;
+ }
+ switch (b) {
+ case 0x01:
+ case 0x03:
+ case 0x04:
+ case 0x05:
+ case 0x0B:
+ case 0x0C:
+ case 0x15:
+ case 0x17:
+ case 0x19:
+ case 0x1F: {
+ xbuf_reset(&p->seq);
+ xbuf_push(&p->seq, (char)b);
+ int k;
+ switch (b) {
+ case 0x01: k = KEY_CTRL_A; break;
+ case 0x03: k = KEY_CTRL_C; break;
+ case 0x04: k = KEY_CTRL_D; break;
+ case 0x05: k = KEY_CTRL_E; break;
+ case 0x0B: k = KEY_CTRL_K; break;
+ case 0x0C: k = KEY_CTRL_L; break;
+ case 0x15: k = KEY_CTRL_U; break;
+ case 0x19: k = KEY_CTRL_Y; break;
+ case 0x1F: k = KEY_UNDO; break;
+ default: k = KEY_CTRL_W; break;
+ }
+ /* A bare Ctrl+C is an interrupt, not pasted text: if a bracketed
+ * paste start (CSI 200~) was received but its 201~ terminator never
+ * arrived, paste mode would otherwise stick and turn every Enter into
+ * a newline. Reset it so the user can submit again. */
+ if (b == 0x03)
+ p->pasting = false;
+ kp_emit(p, out, k, 0);
+ return 1;
+ }
+ default:
+ break;
+ }
+ if (b < 0x20) {
+ xbuf_reset(&p->seq);
+ xbuf_push(&p->seq, (char)b);
+ kp_emit(p, out, KEY_UNKNOWN, 0);
+ return 1;
+ }
+ if (b < 0x80) {
+ xbuf_reset(&p->seq);
+ xbuf_push(&p->seq, (char)b);
+ kp_emit(p, out, KEY_CHAR, b);
+ return 1;
+ }
+ return kp_utf8_step(p, b, out);
+}
+
+static int kp_escape(key_parser *p, unsigned char b, evlist *out) {
+ p->state = KST_GROUND;
+ xbuf_push(&p->seq, (char)b);
+ if (b == '[') {
+ p->state = KST_CSI;
+ xbuf_reset(&p->csi_params);
+ return 0;
+ }
+ if (b == 'O') {
+ p->state = KST_SS3;
+ return 0;
+ }
+ if (b >= 0x20 && b <= 0x7E) {
+ kp_emit(p, out, KEY_CHAR, b);
+ return 1;
+ }
+ kp_emit(p, out, KEY_ESC, 0);
+ return 1;
+}
+
+static int kp_csi(key_parser *p, unsigned char b, evlist *out) {
+ xbuf_push(&p->seq, (char)b);
+ if (kp_digit(b) || b == ';' || b == '<' || b == '=' || b == '?' || b == '>') {
+ xbuf_push(&p->csi_params, (char)b);
+ return 0;
+ }
+ p->state = KST_GROUND;
+ char *params = xbuf_steal(&p->csi_params);
+ int k = -1;
+ if (b == 'A')
+ k = KEY_UP;
+ else if (b == 'B')
+ k = KEY_DOWN;
+ else if (b == 'C')
+ k = KEY_RIGHT;
+ else if (b == 'D')
+ k = KEY_LEFT;
+ else if (b == 'H')
+ k = KEY_HOME;
+ else if (b == 'F')
+ k = KEY_END;
+ else if (b == 'Z')
+ k = KEY_BACKTAB;
+ else if (b == 'u')
+ k = csi_u(params, &k) ? k : -1;
+ else if (b == '~') {
+ unsigned long n;
+ if (first_num(params, &n)) {
+ switch (n) {
+ case 200u:
+ free(params);
+ p->pasting = true;
+ return 0;
+ case 201u:
+ free(params);
+ p->pasting = false;
+ return 0;
+ case 1u:
+ case 7u:
+ k = KEY_HOME;
+ break;
+ case 3u:
+ k = KEY_DELETE;
+ break;
+ case 4u:
+ case 8u:
+ k = KEY_END;
+ break;
+ case 5u:
+ k = KEY_PAGEUP;
+ break;
+ case 6u:
+ k = KEY_PAGEDOWN;
+ break;
+ default:
+ k = KEY_UNKNOWN;
+ break;
+ }
+ } else {
+ k = KEY_UNKNOWN;
+ }
+ }
+ else if (b == 'R' || b == 'M' || b == 'h' || b == 'l' || b == 'm')
+ k = -1;
+ free(params);
+ if (k < 0) return 0;
+ kp_emit(p, out, k, 0);
+ return 1;
+}
+
+static int kp_ss3(key_parser *p, unsigned char b, evlist *out) {
+ xbuf_push(&p->seq, (char)b);
+ p->state = KST_GROUND;
+ int k;
+ switch (b) {
+ case 'A': k = KEY_UP; break;
+ case 'B': k = KEY_DOWN; break;
+ case 'C': k = KEY_RIGHT; break;
+ case 'D': k = KEY_LEFT; break;
+ case 'H': k = KEY_HOME; break;
+ case 'F': k = KEY_END; break;
+ default: k = KEY_UNKNOWN; break;
+ }
+ kp_emit(p, out, k, 0);
+ return 1;
+}
+
+size_t key_parser_feed(key_parser *p, const unsigned char *in, size_t n, evlist *out) {
+ size_t emitted = 0;
+ for (size_t i = 0; i < n; i++) {
+ unsigned char b = in[i];
+ switch (p->state) {
+ case KST_ESCAPE:
+ emitted += kp_escape(p, b, out);
+ break;
+ case KST_CSI:
+ emitted += kp_csi(p, b, out);
+ break;
+ case KST_SS3:
+ emitted += kp_ss3(p, b, out);
+ break;
+ default:
+ emitted += kp_ground(p, b, out);
+ break;
+ }
+ }
+ return emitted;
+}
+
+int key_parser_flush(key_parser *p, evlist *out) {
+ if (p->state != KST_ESCAPE) return 0;
+ p->state = KST_GROUND;
+ kp_emit(p, out, KEY_ESC, 0);
+ return 1;
+}
+
+/* ================================================================== */
+/* Editor */
+/* ================================================================== */
+
+static size_t char_start(const char *s, size_t pos) {
+ size_t len = strlen(s);
+ if (pos > len) pos = len;
+ size_t j = pos;
+ while (j > 0 && (((unsigned char)s[j - 1]) & 0xC0) == 0x80) j--;
+ return j == 0 ? 0 : j - 1;
+}
+
+static size_t char_end(const char *s, size_t len, size_t pos) {
+ if (pos >= len) return len;
+ size_t cp;
+ const char *nxt = utf8_next(s + pos, s + len, &cp);
+ return (size_t)(nxt - s);
+}
+
+/* Byte offset of the first column of the logical line holding `pos`. */
+static size_t line_start_of(const char *s, size_t pos) {
+ size_t len = strlen(s);
+ if (pos > len) pos = len;
+ size_t i = pos;
+ while (i > 0 && s[i - 1] != '\n') i--;
+ return i;
+}
+
+/* Byte offset of the line terminator ('\n') for the line holding `pos`, or
+ * end-of-string when that line is the last one. */
+static size_t line_end_of(const char *s, size_t pos) {
+ size_t len = strlen(s);
+ if (pos > len) pos = len;
+ size_t i = pos;
+ while (i < len && s[i] != '\n') i++;
+ return i;
+}
+
+/* Physical (row, col) of a byte offset in the buffer, tracking the *visual*
+ * cursor position the way a delayed-wrap terminal (xterm/VTE) actually keeps
+ * it: writing a character into the last column leaves the cursor sitting on
+ * that last column with a pending wrap, the wrap fires only when the *next*
+ * character is written, and a newline clears the pending wrap. So the cursor
+ * never sits on an empty spurious row — a full-width line followed by a
+ * newline does not gain one. Line 0 is prefixed by the 2-column CHAT_PROMPT.
+ * The returned (row, col) is exactly where the terminal cursor is after the
+ * text up to that byte has been printed, so relative cursor moves from it are
+ * reliable. */
+static void input_wrap_caret_impl(const char *text, size_t caret, size_t w,
+ size_t *out_row, size_t *out_col,
+ bool *out_pending) {
+ if (w == 0) w = 1;
+ size_t len = text ? strlen(text) : 0;
+ if (caret > len) caret = len;
+ size_t r = 0, c = 0;
+ int pending = 0;
+#define ADV_COL() do { \
+ if (pending) { pending = 0; r++; c = 0; } \
+ c++; \
+ if (c == w) { pending = 1; c = w - 1; } \
+ } while (0)
+ /* The 2-column prompt is always printed in front of line 0. */
+ ADV_COL();
+ ADV_COL();
+ size_t byte = 0;
+ while (byte < caret) {
+ size_t cp;
+ const char *nxt = utf8_next(text + byte, text + len, &cp);
+ size_t cl = (size_t)(nxt - (text + byte));
+ if (cl == 0) cl = 1;
+ if (cp == '\n') {
+ pending = 0;
+ c = 0;
+ r++;
+ } else {
+ ADV_COL();
+ }
+ byte += cl;
+ }
+#undef ADV_COL
+ if (out_row) *out_row = r;
+ if (out_col) *out_col = c;
+ if (out_pending) *out_pending = (pending != 0);
+}
+
+void input_wrap_caret(const char *text, size_t caret, size_t w,
+ size_t *out_row, size_t *out_col) {
+ input_wrap_caret_impl(text, caret, w, out_row, out_col, NULL);
+}
+
+bool input_wrap_pending(const char *text, size_t caret, size_t w) {
+ bool pending = false;
+ input_wrap_caret_impl(text, caret, w, NULL, NULL, &pending);
+ return pending;
+}
+
+/* Total physical rows the buffer occupies when printed, i.e. the row of the
+ * visual end-of-text cursor plus one. */
+size_t input_wrap_total(const char *text, size_t w) {
+ size_t r, c;
+ const char *t = text ? text : "";
+ input_wrap_caret(t, strlen(t), w, &r, &c);
+ return r + 1;
+}
+
+static char *splice_remove(const char *s, size_t start, size_t end) {
+ size_t len = strlen(s);
+ char *out = (char *) xmalloc(len - (end - start) + 1);
+ memcpy(out, s, start);
+ memcpy(out + start, s + end, len - end);
+ out[len - (end - start)] = '\0';
+ return out;
+}
+
+static size_t word_backward(const char *s, size_t byte) {
+ size_t i = byte;
+ while (i > 0 && s[i - 1] == ' ') i--;
+ while (i > 0 &&
+ (isalnum((unsigned char)s[i - 1]) || s[i - 1] == '_'))
+ i--;
+ while (i > 0 && s[i - 1] == ' ') i--;
+ return i;
+}
+
+#define EDITOR_UNDO_LIMIT 100
+
+void editor_init(editor *e) {
+ e->text = xstrdup("");
+ e->caret = 0;
+ e->killed = NULL;
+ e->undo = NULL;
+ e->undo_len = 0;
+ e->undo_cap = 0;
+}
+
+void editor_free(editor *e) {
+ free(e->text);
+ for (size_t i = 0; i < e->undo_len; i++)
+ free(e->undo[i].text);
+ free(e->undo);
+ free(e->killed);
+ memset(e, 0, sizeof(*e));
+}
+
+/* Snapshot the current buffer onto the bounded undo stack. */
+static void editor_record(editor *e) {
+ if (e->undo_len == e->undo_cap) {
+ size_t ncap = e->undo_cap ? e->undo_cap * 2 : 16;
+ if (ncap > EDITOR_UNDO_LIMIT) {
+ free(e->undo[0].text);
+ memmove(e->undo, e->undo + 1, (e->undo_len - 1) * sizeof(*e->undo));
+ e->undo_len--;
+ } else {
+ e->undo = (editor_undo_entry *) xrealloc(e->undo, ncap * sizeof(*e->undo));
+ e->undo_cap = ncap;
+ }
+ }
+ e->undo[e->undo_len].text = xstrdup(e->text ? e->text : "");
+ e->undo[e->undo_len].caret = e->caret;
+ e->undo_len++;
+}
+
+/* Restore the most recent snapshot. False when there is nothing to undo. */
+static bool editor_undo_pop(editor *e) {
+ if (e->undo_len == 0)
+ return false;
+ e->undo_len--;
+ free(e->text);
+ e->text = e->undo[e->undo_len].text;
+ e->undo[e->undo_len].text = NULL;
+ e->caret = e->undo[e->undo_len].caret;
+ return true;
+}
+
+void editor_clear(editor *e) {
+ e->text[0] = '\0';
+ e->caret = 0;
+}
+
+void editor_set(editor *e, const char *text) {
+ free(e->text);
+ e->text = xstrdup(text);
+ e->caret = strlen(e->text);
+}
+
+char *editor_text(const editor *e) {
+ return xstrdup(e->text);
+}
+
+bool editor_is_empty(const editor *e) {
+ return e->text[0] == '\0';
+}
+
+size_t editor_line_count(const editor *e) {
+ size_t n = 1;
+ for (const char *p = e->text; *p; p++)
+ if (*p == '\n') n++;
+ return n;
+}
+
+size_t editor_caret_line(const editor *e) {
+ size_t n = 0;
+ for (size_t i = 0; i < e->caret && e->text[i]; i++)
+ if (e->text[i] == '\n') n++;
+ return n;
+}
+
+static void ed_insert_str(editor *e, const char *s) {
+ size_t len = strlen(e->text);
+ if (e->caret > len) e->caret = len;
+ size_t ins = strlen(s);
+ char *nt = (char *) xmalloc(len + ins + 1);
+ memcpy(nt, e->text, e->caret);
+ memcpy(nt + e->caret, s, ins);
+ memcpy(nt + e->caret + ins, e->text + e->caret, len - e->caret);
+ nt[len + ins] = '\0';
+ free(e->text);
+ e->text = nt;
+ e->caret += ins;
+}
+
+static void ed_insert_char(editor *e, size_t cp) {
+ char tmp[8];
+ size_t i = 0;
+ if (cp < 0x80)
+ tmp[i++] = (char)cp;
+ else if (cp < 0x800) {
+ tmp[i++] = (char)(0xC0 | (cp >> 6));
+ tmp[i++] = (char)(0x80 | (cp & 0x3F));
+ } else if (cp < 0x10000) {
+ tmp[i++] = (char)(0xE0 | (cp >> 12));
+ tmp[i++] = (char)(0x80 | ((cp >> 6) & 0x3F));
+ tmp[i++] = (char)(0x80 | (cp & 0x3F));
+ } else {
+ tmp[i++] = (char)(0xF0 | (cp >> 18));
+ tmp[i++] = (char)(0x80 | ((cp >> 12) & 0x3F));
+ tmp[i++] = (char)(0x80 | ((cp >> 6) & 0x3F));
+ tmp[i++] = (char)(0x80 | (cp & 0x3F));
+ }
+ tmp[i] = '\0';
+ ed_insert_str(e, tmp);
+}
+
+static void ed_backspace(editor *e) {
+ if (e->caret > 0) {
+ size_t s = char_start(e->text, e->caret);
+ char *killed = xstrndup(e->text + s, e->caret - s);
+ char *nt = splice_remove(e->text, s, e->caret);
+ free(e->text);
+ e->text = nt;
+ e->caret = s;
+ free(e->killed);
+ e->killed = killed;
+ }
+}
+
+static void ed_delete(editor *e) {
+ size_t len = strlen(e->text);
+ if (e->caret < len) {
+ size_t c = char_end(e->text, len, e->caret);
+ char *killed = xstrndup(e->text + e->caret, c - e->caret);
+ char *nt = splice_remove(e->text, e->caret, c);
+ free(e->text);
+ e->text = nt;
+ free(e->killed);
+ e->killed = killed;
+ }
+}
+
+/* Move the caret to the previous (dir<0) or next (dir>0) line, keeping the
+ * column and clamping it to the target line's length on a UTF-8 boundary.
+ * No-op at the top/bottom edge. */
+static void ed_move_line(editor *e, int dir) {
+ const char *s = e->text;
+ size_t len = strlen(s);
+ if (e->caret > len) e->caret = len;
+ size_t start = e->caret;
+ while (start > 0 && s[start - 1] != '\n') start--;
+ size_t col = e->caret - start;
+ if (dir < 0) {
+ if (start == 0) return; /* first line */
+ size_t pstart = start - 1;
+ while (pstart > 0 && s[pstart - 1] != '\n') pstart--;
+ size_t plen = (start - 1) - pstart;
+ size_t c = col < plen ? col : plen;
+ while (c > 0 && (((unsigned char)s[pstart + c]) & 0xC0) == 0x80) c--;
+ e->caret = pstart + c;
+ } else {
+ size_t end = e->caret;
+ while (end < len && s[end] != '\n') end++;
+ if (end >= len) return; /* last line */
+ size_t nstart = end + 1;
+ size_t nend = nstart;
+ while (nend < len && s[nend] != '\n') nend++;
+ size_t nlen = nend - nstart;
+ size_t c = col < nlen ? col : nlen;
+ while (c > 0 && (((unsigned char)s[nstart + c]) & 0xC0) == 0x80) c--;
+ e->caret = nstart + c;
+ }
+}
+
+void editor_apply(editor *e, input_key key, input_action *a) {
+ a->kind = ACT_NONE;
+ a->text = NULL;
+ switch (key.kind) {
+ case KEY_CHAR:
+ editor_record(e);
+ ed_insert_char(e, key.cp);
+ break;
+ case KEY_TAB:
+ editor_record(e);
+ ed_insert_str(e, "\t");
+ break;
+ case KEY_NEWLINE: /* pasted CR/LF: insert a real newline */
+ editor_record(e);
+ ed_insert_str(e, "\n");
+ break;
+ case KEY_ENTER: {
+ /* Backslash + Enter: line continuation, not submit. Universal across
+ * terminals (works where Shift+Enter is not distinguishable from
+ * Enter). The backslash is the char immediately before the caret on
+ * its own line -- so a middle line of a multi-line buffer can be
+ * split, not just the last line. */
+ if (e->caret > 0 && e->text[e->caret - 1] == '\\') {
+ editor_record(e);
+ e->text[e->caret - 1] = '\n';
+ a->kind = ACT_NEWLINE;
+ break;
+ }
+ a->kind = ACT_SUBMIT;
+ a->text = editor_text(e);
+ break;
+ }
+ case KEY_BACKTAB:
+ a->kind = ACT_TOGGLE_MODE;
+ break;
+ case KEY_ESC:
+ a->kind = ACT_STOP;
+ break;
+ case KEY_BACKSPACE:
+ editor_record(e);
+ ed_backspace(e);
+ break;
+ case KEY_DELETE:
+ editor_record(e);
+ ed_delete(e);
+ break;
+ case KEY_LEFT:
+ /* Stay on the current line: the caret never crosses a newline to the
+ * left, so a press at the line start is a no-op. */
+ if (e->caret > line_start_of(e->text, e->caret))
+ e->caret = char_start(e->text, e->caret);
+ break;
+ case KEY_RIGHT:
+ if (e->caret < line_end_of(e->text, e->caret))
+ e->caret = char_end(e->text, strlen(e->text), e->caret);
+ break;
+ case KEY_HOME:
+ case KEY_CTRL_A:
+ e->caret = line_start_of(e->text, e->caret);
+ break;
+ case KEY_END:
+ case KEY_CTRL_E:
+ e->caret = line_end_of(e->text, e->caret);
+ break;
+ case KEY_CTRL_U:
+ editor_record(e);
+ if (e->caret > 0) {
+ char *killed = xstrndup(e->text, e->caret);
+ char *keep = xstrdup(e->text + e->caret);
+ free(e->text);
+ e->text = keep;
+ e->caret = 0;
+ free(e->killed);
+ e->killed = killed;
+ }
+ break;
+ case KEY_CTRL_K: {
+ editor_record(e);
+ size_t len = strlen(e->text);
+ if (e->caret < len) {
+ char *killed = xstrdup(e->text + e->caret);
+ char *keep = xstrndup(e->text, e->caret);
+ free(e->text);
+ e->text = keep;
+ free(e->killed);
+ e->killed = killed;
+ }
+ break;
+ }
+ case KEY_CTRL_W: {
+ editor_record(e);
+ size_t i = word_backward(e->text, e->caret);
+ if (i < e->caret) {
+ char *killed = xstrndup(e->text + i, e->caret - i);
+ char *nt = splice_remove(e->text, i, e->caret);
+ free(e->text);
+ e->text = nt;
+ e->caret = i;
+ free(e->killed);
+ e->killed = killed;
+ }
+ break;
+ }
+ case KEY_CTRL_Y:
+ if (e->killed && e->killed[0]) {
+ editor_record(e);
+ ed_insert_str(e, e->killed);
+ }
+ break;
+ case KEY_UNDO:
+ editor_undo_pop(e);
+ break;
+ case KEY_CTRL_C:
+ if (editor_is_empty(e))
+ a->kind = ACT_QUIT;
+ else {
+ editor_record(e);
+ editor_clear(e);
+ }
+ break;
+ case KEY_CTRL_D:
+ if (editor_is_empty(e))
+ a->kind = ACT_QUIT;
+ else
+ ed_delete(e);
+ break;
+ case KEY_CTRL_L:
+ a->kind = ACT_CLEAR_SCREEN;
+ break;
+ case KEY_UP:
+ ed_move_line(e, -1);
+ break;
+ case KEY_DOWN:
+ ed_move_line(e, +1);
+ break;
+ case KEY_PAGEUP:
+ case KEY_PAGEDOWN:
+ case KEY_UNKNOWN:
+ break;
+ }
+}
+
+/* ================================================================== */
+/* History */
+/* ================================================================== */
+
+input_history *input_history_new(void) {
+ input_history *h = (input_history *) xmalloc(sizeof(*h));
+ sl_init(&h->entries);
+ h->head = 0;
+ h->active = false;
+ h->index = 0;
+ h->snapshot = NULL;
+ h->path = NULL;
+ return h;
+}
+
+void input_history_free(input_history *h) {
+ sl_free(&h->entries);
+ free(h->snapshot);
+ free(h->path);
+ free(h);
+}
+
+void input_history_reset(input_history *h) {
+ h->active = false;
+ h->index = 0;
+ free(h->snapshot);
+ h->snapshot = NULL;
+}
+
+static size_t hist_live(input_history *h) {
+ return h->entries.len > h->head ? h->entries.len - h->head : 0;
+}
+
+static void history_evict_head(input_history *h) {
+ h->head++;
+ /* Amortized O(1): once half the storage is dead, free the dead prefix
+ * and slide the live entries to the front (absolute `index` moves with
+ * them). Evicted-but-uncompacted entries are freed by sl_free. */
+ if (h->head * 2 >= h->entries.len) {
+ size_t live = h->entries.len - h->head;
+ for (size_t i = 0; i < h->head; i++)
+ free(h->entries.items[i]);
+ memmove(h->entries.items, h->entries.items + h->head,
+ live * sizeof(*h->entries.items));
+ h->entries.len = live;
+ if (h->active)
+ h->index -= h->head;
+ h->head = 0;
+ }
+}
+
+static int make_parent_dirs(const char *path) {
+ char *copy = xstrdup(path);
+ for (char *p = copy + 1; *p; p++) {
+ if (*p == '/') {
+ *p = '\0';
+ if (copy[0] && mkdir(copy, 0755) != 0 && errno != EEXIST) {
+ free(copy);
+ return -1;
+ }
+ *p = '/';
+ }
+ }
+ free(copy);
+ return 0;
+}
+
+bool input_history_persist(input_history *h) {
+ if (!h->path) return false;
+ cJSON *arr = cJSON_CreateArray();
+ for (size_t i = h->head; i < h->entries.len; i++)
+ cJSON_AddItemToArray(arr, cJSON_CreateString(h->entries.items[i]));
+ char *s = cJSON_PrintUnformatted(arr);
+ cJSON_Delete(arr);
+ char *err = NULL;
+ bool ok = make_parent_dirs(h->path) == 0 &&
+ write_file_from_buf(h->path, s, strlen(s), &err) == 0;
+ free(err);
+ free(s);
+ return ok;
+}
+
+input_history *input_history_open(const char *path) {
+ input_history *h = input_history_new();
+ h->path = xstrdup(path);
+ char *data = NULL;
+ size_t len = 0;
+ char *err = NULL;
+ int rc = read_file_to_buf(path, &data, &len, &err);
+ if (rc == 0) {
+ cJSON *arr = data ? cJSON_Parse(data) : NULL;
+ free(data);
+ if (arr && cJSON_IsArray(arr)) {
+ int n = cJSON_GetArraySize(arr);
+ for (int i = 0; i < n; i++) {
+ cJSON *it = cJSON_GetArrayItem(arr, i);
+ if (cJSON_IsString(it) && it->valuestring)
+ sl_push(&h->entries, xstrdup(it->valuestring));
+ }
+ }
+ cJSON_Delete(arr);
+ } else {
+ free(err);
+ if (errno != ENOENT) {
+ input_history_free(h);
+ return NULL;
+ }
+ }
+ while (hist_live(h) > LLSH_HISTORY_LIMIT) history_evict_head(h);
+ return h;
+}
+
+void input_history_push(input_history *h, const char *text) {
+ char *trimmed = xstr_trim_dup(text);
+ if (!trimmed[0]) {
+ free(trimmed);
+ input_history_reset(h);
+ return;
+ }
+ if (hist_live(h) > 0) {
+ const char *last = h->entries.items[h->entries.len - 1];
+ if (xstreq(last, trimmed)) {
+ free(trimmed);
+ input_history_reset(h);
+ return;
+ }
+ }
+ if (hist_live(h) == LLSH_HISTORY_LIMIT) history_evict_head(h);
+ sl_push(&h->entries, trimmed);
+ input_history_reset(h);
+ input_history_persist(h);
+}
+
+char *input_history_walk(input_history *h, int dir, size_t caret_line,
+ const char *current) {
+ if (dir < 0) {
+ /* Up: walk newest -> oldest. While composing (not active) engage
+ * only from the first line so a mid-buffer Up still moves the caret;
+ * the first Up snapshots the line being edited so a Down can restore
+ * it. Mid-walk (active) the press always advances, regardless of
+ * where the caret now sits. */
+ if (!h->active) {
+ if (hist_live(h) == 0 || caret_line != 0)
+ return NULL;
+ free(h->snapshot);
+ h->snapshot = xstrdup(current ? current : "");
+ h->active = true;
+ h->index = h->entries.len - 1;
+ } else if (h->index == h->head) {
+ return NULL; /* oldest already shown: hand back to the caret */
+ } else {
+ h->index -= 1;
+ }
+ return xstrdup(h->entries.items[h->index]);
+ }
+
+ /* Down: only meaningful mid-walk. Step back toward the newest entry, or,
+ * past the newest, hand back the pre-recall line and end the walk.
+ * While composing a Down never recalls — it moves the caret. */
+ if (!h->active)
+ return NULL;
+ if (h->index + 1 < h->entries.len) {
+ h->index += 1;
+ return xstrdup(h->entries.items[h->index]);
+ }
+ h->active = false;
+ char *s = h->snapshot;
+ h->snapshot = NULL;
+ return s;
+}
+
+/* ================================================================== */
+/* Terminal */
+/* ================================================================== */
+
+int input_isatty(void) {
+ return isatty(STDIN_FILENO);
+}
+
+int input_setup_terminal(void) {
+ struct termios orig;
+ if (tcgetattr(STDIN_FILENO, &orig) != 0) return -1;
+ struct termios raw = orig;
+ raw.c_iflag &= ~((tcflag_t)(BRKINT | ICRNL | INPCK | ISTRIP | IXON));
+ raw.c_lflag &= ~((tcflag_t)(ECHO | ICANON | IEXTEN | ISIG));
+ raw.c_oflag &= ~((tcflag_t)OPOST);
+ raw.c_cc[VMIN] = 1;
+ raw.c_cc[VTIME] = 0;
+ if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) != 0) return -1;
+ struct termios t;
+ if (tcgetattr(STDIN_FILENO, &t) == 0) {
+ t.c_oflag |= (tcflag_t)(OPOST | ONLCR);
+ (void)tcsetattr(STDIN_FILENO, TCSANOW, &t);
+ }
+ const char *init = "\x1b[>1u\x1b[?2004h";
+ (void)!write(STDOUT_FILENO, init, strlen(init));
+ fflush(stdout);
+ return 0;
+}
+
+void input_teardown_terminal(void) {
+ const char *fin = "\x1b[?2004l\x1b[>0u";
+ (void)!write(STDOUT_FILENO, fin, strlen(fin));
+ fflush(stdout);
+ struct termios t;
+ if (tcgetattr(STDIN_FILENO, &t) != 0) return;
+ t.c_lflag |= (tcflag_t)(ICANON | ECHO | ISIG | IEXTEN);
+ t.c_iflag |= (tcflag_t)(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
+ t.c_oflag |= (tcflag_t)(OPOST | ONLCR);
+ t.c_cc[VMIN] = 1;
+ t.c_cc[VTIME] = 0;
+ (void)tcsetattr(STDIN_FILENO, TCSANOW, &t);
+}
+
+/* ================================================================== */
+/* Reader */
+/* ================================================================== */
+
+struct input_key_reader {
+ reader_ctx *ctx;
+ pthread_t tid;
+ int started;
+ volatile int stop;
+ int exit;
+ pthread_mutex_t qlock;
+ evlist q;
+};
+
+input_key_reader *input_key_reader_new(reader_ctx *ctx) {
+ input_key_reader *r = (input_key_reader *) xmalloc(sizeof(*r));
+ memset(r, 0, sizeof(*r));
+ r->ctx = ctx;
+ pthread_mutex_init(&r->qlock, NULL);
+ evlist_init(&r->q);
+ return r;
+}
+
+void input_key_reader_send(input_key_reader *r, const input_key *k) {
+ pthread_mutex_lock(&r->qlock);
+ evlist_push(&r->q, *k, (const unsigned char *)"", 0);
+ pthread_mutex_unlock(&r->qlock);
+}
+
+void input_key_reader_send_raw(input_key_reader *r, const input_key *k,
+ const char *raw) {
+ pthread_mutex_lock(&r->qlock);
+ evlist_push(&r->q, *k, (const unsigned char *)raw, strlen(raw));
+ pthread_mutex_unlock(&r->qlock);
+}
+
+bool input_key_reader_recv(input_key_reader *r, input_key *out,
+ const unsigned char **raw, size_t *raw_len) {
+ pthread_mutex_lock(&r->qlock);
+ if (r->q.len == 0) {
+ pthread_mutex_unlock(&r->qlock);
+ return false;
+ }
+ *out = r->q.items[r->q.head];
+ size_t n = r->q.lens[r->q.head];
+ if (raw) {
+ /* The queue frees its slot on pop, so hand the caller an owned copy;
+ * a borrowed pointer here was freed before the caller used it. */
+ unsigned char *copy = (unsigned char* ) xmalloc(n + 1);
+ memcpy(copy, r->q.raws[r->q.head], n);
+ copy[n] = '\0';
+ *raw = copy;
+ }
+ if (raw_len) *raw_len = n;
+ evlist_pop(&r->q);
+ pthread_mutex_unlock(&r->qlock);
+ return true;
+}
+
+void input_raw_free(const unsigned char *raw) {
+ free((void *)raw);
+}
+
+/* Queue the event and, if set, invoke the direct hook.
+ * Returns non-zero to stop reading (receiver gone). */
+static int reader_dispatch(input_key_reader *r, const input_key *k,
+ const unsigned char *raw, size_t raw_len) {
+ pthread_mutex_lock(&r->qlock);
+ evlist_push(&r->q, *k, raw, raw_len);
+ pthread_mutex_unlock(&r->qlock);
+ if (r->ctx && r->ctx->send) return r->ctx->send(r, k, raw, raw_len);
+ return 0;
+}
+
+static void *reader_thread(void *arg) {
+ input_key_reader *r = (input_key_reader *) arg;
+ unsigned char buf[512];
+ for (;;) {
+ if (r->stop) break;
+ struct pollfd pfd = {.fd = STDIN_FILENO, .events = POLLIN, .revents = 0};
+ int pr = poll(&pfd, 1, 50);
+ if (pr < 0) {
+ if (errno == EINTR) continue;
+ break;
+ }
+ if (pr == 0) continue;
+ if ((pfd.revents & (POLLIN | POLLHUP | POLLERR)) == 0) continue;
+ ssize_t n = 0;
+ do {
+ n = read(STDIN_FILENO, buf, sizeof buf);
+ } while (n < 0 && errno == EINTR);
+ if (n < 0) break;
+ if (n == 0) break; /* EOF: no synthetic Ctrl-D */
+ evlist evs;
+ evlist_init(&evs);
+ key_parser_feed(r->ctx->parser, buf, (size_t)n, &evs);
+ for (size_t j = 0; j < evs.len; j++) {
+ if (reader_dispatch(r, &evs.items[j], evs.raws[j],
+ evs.lens[j]) != 0) {
+ evlist_free(&evs);
+ r->exit = 1;
+ return NULL;
+ }
+ }
+ evlist_free(&evs);
+ key_parser_flush(r->ctx->parser, &evs);
+ for (size_t j = 0; j < evs.len; j++) {
+ if (reader_dispatch(r, &evs.items[j], evs.raws[j],
+ evs.lens[j]) != 0) {
+ evlist_free(&evs);
+ r->exit = 1;
+ return NULL;
+ }
+ }
+ evlist_free(&evs);
+ }
+ r->exit = 1;
+ return NULL;
+}
+
+int input_key_reader_start(input_key_reader *r) {
+ r->stop = 0;
+ r->exit = 0;
+ if (pthread_create(&r->tid, NULL, reader_thread, r) != 0) return -1;
+ r->started = 1;
+ return 0;
+}
+
+void input_key_reader_stop(input_key_reader *r) {
+ if (!r->started) return;
+ r->stop = 1;
+ /* The thread notices stop within one poll timeout (50 ms). */
+ pthread_join(r->tid, NULL);
+ r->started = 0;
+}
+
+void input_key_reader_free(input_key_reader *r) {
+ input_key_reader_stop(r);
+ evlist_free(&r->q);
+ pthread_mutex_destroy(&r->qlock);
+ if (r->ctx) {
+ if (r->ctx->parser) {
+ key_parser_free(r->ctx->parser);
+ free(r->ctx->parser);
+ }
+ free(r->ctx);
+ }
+ free(r);
+}
+
+/* ================================================================== */
+/* Shell shadow */
+/* ================================================================== */
+
+char *shell_shadow_apply(editor *shadow, input_key key) {
+ input_action a;
+ memset(&a, 0, sizeof(a));
+ editor_apply(shadow, key, &a);
+ if (a.kind == ACT_SUBMIT && a.text) {
+ size_t n;
+ xtrim_span(a.text, strlen(a.text), &n);
+ if (n > 0) {
+ char *text = a.text;
+ editor_clear(shadow);
+ a.text = NULL;
+ return text;
+ }
+ }
+ input_action_free(&a);
+ return NULL;
+}
+
+char *input_history_path_for(const char *config_dir) {
+ size_t n = strlen(config_dir);
+ char *p = (char *) xmalloc(n + 1 + 14); /* "/llsh/history\0" */
+ memcpy(p, config_dir, n);
+ memcpy(p + n, "/llsh/history\0", 15);
+ return p;
+}
+
+char *input_default_config_dir(void) {
+ const char *xdg = getenv("XDG_CONFIG_HOME");
+ if (xdg && xdg[0] == '/') return xstrdup(xdg);
+ const char *home = getenv("HOME");
+ if (home && home[0]) {
+ size_t n = strlen(home);
+ char *p = (char *) xmalloc(n + 1 + 8); /* "/.config\0" */
+ memcpy(p, home, n);
+ memcpy(p + n, "/.config\0", 9);
+ return p;
+ }
+ return xstrdup("/tmp");
+}
+
+char *input_default_history_path(void) {
+ char *dir = input_default_config_dir();
+ char *p = input_history_path_for(dir);
+ free(dir);
+ return p;
+}
diff --git a/src/input.h b/src/input.h
new file mode 100644
index 0000000..2eee9e1
--- /dev/null
+++ b/src/input.h
@@ -0,0 +1,253 @@
+#ifndef LLSH_INPUT_H
+#define LLSH_INPUT_H
+
+#include "util.h"
+#include <pthread.h>
+#include <stdbool.h>
+#include <stddef.h>
+
+/* ------------------------------------------------------------------ */
+/* Keys (decoded) */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ int kind;
+ unsigned int cp; /* KEY_CHAR: codepoint */
+} input_key;
+
+enum {
+ KEY_CHAR,
+ KEY_BACKSPACE,
+ KEY_DELETE,
+ KEY_LEFT,
+ KEY_RIGHT,
+ KEY_UP,
+ KEY_DOWN,
+ KEY_HOME,
+ KEY_END,
+ KEY_PAGEUP,
+ KEY_PAGEDOWN,
+ KEY_ENTER,
+ KEY_NEWLINE,
+ KEY_TAB,
+ KEY_BACKTAB,
+ KEY_ESC,
+ KEY_CTRL_A,
+ KEY_CTRL_C,
+ KEY_CTRL_D,
+ KEY_CTRL_E,
+ KEY_CTRL_K,
+ KEY_CTRL_L,
+ KEY_CTRL_U,
+ KEY_CTRL_W,
+ KEY_CTRL_Y,
+ KEY_UNDO,
+ KEY_UNKNOWN,
+};
+
+/* ------------------------------------------------------------------ */
+/* Actions */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ int kind;
+ char *text; /* ACT_SUBMIT: owned buffer */
+} input_action;
+
+enum {
+ ACT_NONE,
+ ACT_SUBMIT,
+ ACT_QUIT,
+ ACT_STOP,
+ ACT_TOGGLE_MODE,
+ ACT_CLEAR_SCREEN,
+ /* Enter on a trailing backslash: line continuation (newline inserted). */
+ ACT_NEWLINE,
+};
+
+void input_action_free(input_action *a);
+
+/* ------------------------------------------------------------------ */
+/* Event list (owned) */
+/* ------------------------------------------------------------------ */
+
+/* True circular FIFO: pushes/pops advance indices modulo `cap` and never
+ * shift live elements. */
+typedef struct {
+ input_key *items;
+ unsigned char **raws;
+ size_t *lens;
+ size_t len;
+ size_t head;
+ size_t cap;
+} evlist;
+
+void evlist_init(evlist *l);
+void evlist_free(evlist *l);
+void evlist_push(evlist *l, input_key k, const unsigned char *raw, size_t raw_len);
+/* Remove the oldest event (frees its raw buffer). No-op when empty. */
+void evlist_pop(evlist *l);
+
+/* ------------------------------------------------------------------ */
+/* Parser */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ int state; /* KST_* */
+ xbuf seq; /* raw sequence bytes (ESC + rest) */
+ xbuf csi_params;
+ unsigned char utf8[8];
+ size_t utf8_len;
+ bool pasting; /* bracketed paste mode (CSI 200/201) */
+} key_parser;
+
+enum { KST_GROUND, KST_ESCAPE, KST_CSI, KST_SS3 };
+
+void key_parser_init(key_parser *p);
+void key_parser_free(key_parser *p);
+/* Feed a chunk of terminal bytes; emits events into `out`. */
+size_t key_parser_feed(key_parser *p, const unsigned char *in, size_t n, evlist *out);
+/* Drain any partial sequence; emits at most one event. Returns 1 if emitted. */
+int key_parser_flush(key_parser *p, evlist *out);
+
+/* ------------------------------------------------------------------ */
+/* Editor (multi-line) */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ char *text;
+ size_t caret;
+} editor_undo_entry;
+
+typedef struct {
+ char *text; /* owned; lines separated by '\n' */
+ size_t caret; /* byte offset, always on a UTF-8 boundary */
+ char *killed; /* most recent kill/delete, for Ctrl+Y yank */
+ editor_undo_entry *undo; /* bounded undo stack (oldest -> newest) */
+ size_t undo_len;
+ size_t undo_cap;
+} editor;
+
+void editor_init(editor *e);
+void editor_free(editor *e);
+void editor_clear(editor *e);
+void editor_set(editor *e, const char *text);
+char *editor_text(const editor *e);
+bool editor_is_empty(const editor *e);
+/* Number of lines in the buffer (1 when empty). */
+size_t editor_line_count(const editor *e);
+/* 0-based index of the line holding the caret. */
+size_t editor_caret_line(const editor *e);
+void editor_apply(editor *e, input_key key, input_action *a);
+
+/* Terminal-wrap geometry for the multi-line input. These let the renderer and
+ * the tests agree on how a buffer of a given width breaks into physical rows
+ * and where the caret lands, without touching a tty. The model matches the
+ * delayed-wrap auto-wrapping used by xterm/VTE. */
+void input_wrap_caret(const char *text, size_t caret, size_t w,
+ size_t *row, size_t *col);
+size_t input_wrap_total(const char *text, size_t w);
+/* True when the caret sits in the delayed-wrap pending state: on the last
+ * column of a row immediately after printing that row's final character. */
+bool input_wrap_pending(const char *text, size_t caret, size_t w);
+
+/* ------------------------------------------------------------------ */
+/* History */
+/* ------------------------------------------------------------------ */
+
+#define LLSH_HISTORY_LIMIT 100
+
+/* `entries` is oldest -> newest with a ring head: live entries are
+ * items[head .. len), and `index` is an absolute position in items. */
+typedef struct {
+ strlist entries;
+ size_t head;
+ bool active;
+ size_t index;
+ char *snapshot;
+ char *path;
+} input_history;
+
+input_history *input_history_new(void);
+/* Open history at path; loads existing entries. NULL on error. */
+input_history *input_history_open(const char *path);
+void input_history_free(input_history *h);
+void input_history_push(input_history *h, const char *text);
+void input_history_reset(input_history *h);
+
+/* Consolidated Up/Down history policy. dir: -1 for Up, +1 for Down.
+ * caret_line describes the editor so the policy can tell a caret move
+ * (mid-buffer) from a history recall (from the relevant edge, or mid-walk).
+ * Returns an owned entry to load (caller frees), or NULL when the press
+ * should move the caret instead / the walk is exhausted. */
+char *input_history_walk(input_history *h, int dir, size_t caret_line,
+ const char *current);
+bool input_history_persist(input_history *h);
+
+/* ------------------------------------------------------------------ */
+/* Terminal / reader */
+/* ------------------------------------------------------------------ */
+
+/* Enable raw mode + bracketed paste + kitty keyboard protocol. */
+int input_setup_terminal(void);
+/* Restore terminal; write teardown sequences. */
+void input_teardown_terminal(void);
+
+typedef struct input_key_reader input_key_reader;
+
+typedef struct {
+ key_parser *parser;
+ /* Optional direct hook, called for every event in the reader thread. */
+ int (*send)(input_key_reader *r, const input_key *k, const unsigned char *raw,
+ size_t raw_len);
+} reader_ctx;
+
+input_key_reader *input_key_reader_new(reader_ctx *ctx);
+int input_key_reader_start(input_key_reader *r);
+/* Push an event into the reader's queue (used by tests). */
+void input_key_reader_send(input_key_reader *r, const input_key *k);
+/* Push an event with a raw payload into the reader's queue (used by tests). */
+void input_key_reader_send_raw(input_key_reader *r, const input_key *k,
+ const char *raw);
+/* Pop a queued event (non-blocking); false when the queue is empty.
+ * When `raw` is non-NULL the caller receives an owned copy of the event's
+ * raw bytes (the queue frees its own slot on pop); release it with
+ * input_raw_free once consumed. Pass NULL for `raw`/`raw_len` when only the
+ * decoded key is needed. */
+bool input_key_reader_recv(input_key_reader *r, input_key *out,
+ const unsigned char **raw, size_t *raw_len);
+/* Release a raw buffer returned by input_key_reader_recv. NULL-safe. */
+void input_raw_free(const unsigned char *raw);
+void input_key_reader_stop(input_key_reader *r);
+void input_key_reader_free(input_key_reader *r);
+
+/* True when stdin is a terminal (used to gate DSR queries). */
+int input_isatty(void);
+
+/* ------------------------------------------------------------------ */
+/* Shell shadow */
+/* ------------------------------------------------------------------ */
+
+/* Feed one key into the shell-mode shadow editor. If the key finished a
+ * non-empty command, the shadow is cleared and the submitted text is
+ * returned (caller frees); otherwise NULL. */
+char *shell_shadow_apply(editor *shadow, input_key key);
+
+/* History file location: `config_dir/llsh/history`. */
+char *input_history_path_for(const char *config_dir);
+/* $XDG_CONFIG_HOME when set to an absolute path, else $HOME/.config,
+ * else /tmp. */
+char *input_default_config_dir(void);
+/* default_history_path = history_path_for(default_config_dir). */
+char *input_default_history_path(void);
+
+/* ------------------------------------------------------------------ */
+/* Display */
+/* ------------------------------------------------------------------ */
+
+/* Display width in terminal columns (ANSI escapes skipped). */
+size_t input_display_len(const char *s);
+/* Display width of the first n bytes of s. */
+size_t input_display_len_n(const char *s, size_t n);
+
+#endif
diff --git a/src/limits.cpp b/src/limits.cpp
new file mode 100644
index 0000000..bd4b773
--- /dev/null
+++ b/src/limits.cpp
@@ -0,0 +1,275 @@
+#include "limits.h"
+
+#include <ctype.h>
+#include <stdlib.h>
+#include <string.h>
+
+#define DEFAULT_SHELL_TIMEOUT_SECS 120UL
+#define DEFAULT_MAX_OUTPUT_CHARS 16000UL
+#define DEFAULT_MAX_FILE_BYTES 2000000UL
+
+static tool_limits g_limits;
+static bool g_limits_inited = false;
+static bool (*g_confirm_hook)(const char *, void *) = NULL;
+static void *g_confirm_ud = NULL;
+
+/* The global starts zeroed in BSS; make sure it holds defaults before
+ * any consumer reads it (Rust: lazy static initialized to defaults). */
+static void limits_ensure(void) {
+ if (!g_limits_inited) {
+ tool_limits_defaults(&g_limits);
+ g_limits_inited = true;
+ }
+}
+
+void tool_limits_defaults(tool_limits *l) {
+ memset(l, 0, sizeof *l);
+ l->shell_timeout_secs = DEFAULT_SHELL_TIMEOUT_SECS;
+ l->max_output_chars = DEFAULT_MAX_OUTPUT_CHARS;
+ l->max_file_bytes = DEFAULT_MAX_FILE_BYTES;
+ sl_init(&l->shell_allowlist);
+ sl_init(&l->shell_denylist);
+ l->confirm_destructive = false;
+ l->verbose = false;
+}
+
+void tool_limits_set(const tool_limits *l) {
+ limits_ensure();
+ tool_limits_free(&g_limits);
+ g_limits = *l;
+ sl_init(&g_limits.shell_allowlist);
+ sl_init(&g_limits.shell_denylist);
+ for (size_t i = 0; i < l->shell_allowlist.len; i++)
+ sl_push(&g_limits.shell_allowlist, xstrdup(l->shell_allowlist.items[i]));
+ for (size_t i = 0; i < l->shell_denylist.len; i++)
+ sl_push(&g_limits.shell_denylist, xstrdup(l->shell_denylist.items[i]));
+}
+
+const tool_limits *tool_limits_current(void) {
+ limits_ensure();
+ return &g_limits;
+}
+
+void tool_limits_get(tool_limits *out) {
+ limits_ensure();
+ *out = g_limits;
+ sl_init(&out->shell_allowlist);
+ sl_init(&out->shell_denylist);
+ for (size_t i = 0; i < g_limits.shell_allowlist.len; i++)
+ sl_push(&out->shell_allowlist,
+ xstrdup(g_limits.shell_allowlist.items[i]));
+ for (size_t i = 0; i < g_limits.shell_denylist.len; i++)
+ sl_push(&out->shell_denylist, xstrdup(g_limits.shell_denylist.items[i]));
+}
+
+void tool_limits_free(tool_limits *l) {
+ sl_free(&l->shell_allowlist);
+ sl_free(&l->shell_denylist);
+}
+
+static bool entry_matches(const char *entry_raw, const char *command_raw) {
+ size_t elen, clen;
+ const char *entry = xtrim_span(entry_raw, strlen(entry_raw), &elen);
+ const char *command = xtrim_span(command_raw, strlen(command_raw), &clen);
+ if (elen == 0)
+ return true;
+ if (clen == elen && strncmp(entry, command, elen) == 0)
+ return true;
+ /* leading substring on a word boundary */
+ if (clen > elen && strncmp(entry, command, elen) == 0) {
+ char c = command[elen];
+ if (c == ' ' || c == '\t')
+ return true;
+ }
+ /* bare first word */
+ size_t fw = 0;
+ while (fw < clen && !isspace((unsigned char)command[fw]))
+ fw++;
+ return fw == elen && strncmp(entry, command, elen) == 0;
+}
+
+static bool word_eq(const char *w, size_t n, const char *s);
+
+/* Return the likely executable after common command wrappers. This is a
+ * conservative token-level check, not a shell parser. */
+static const char *unwrap_command(const char *command) {
+ const char *p = command;
+ for (int depth = 0; depth < 8; depth++) {
+ while (*p && isspace((unsigned char)*p))
+ p++;
+ const char *word = p;
+ while (*p && !isspace((unsigned char)*p))
+ p++;
+ size_t n = (size_t)(p - word);
+ bool wrapper = word_eq(word, n, "sudo") || word_eq(word, n, "env") ||
+ word_eq(word, n, "command") || word_eq(word, n, "nohup") ||
+ word_eq(word, n, "time") || word_eq(word, n, "nice") ||
+ word_eq(word, n, "busybox");
+ if (!wrapper)
+ return word;
+ bool skip_next = false;
+ while (*p) {
+ while (*p && isspace((unsigned char)*p))
+ p++;
+ if (!*p)
+ return p;
+ const char *next = p;
+ while (*p && !isspace((unsigned char)*p))
+ p++;
+ size_t nn = (size_t)(p - next);
+ if (skip_next) {
+ skip_next = false;
+ continue;
+ }
+ if ((word_eq(word, n, "sudo") || word_eq(word, n, "nice")) &&
+ (word_eq(next, nn, "-u") || word_eq(next, nn, "-g") ||
+ word_eq(next, nn, "-n") || word_eq(next, nn, "-C") ||
+ word_eq(next, nn, "--user") || word_eq(next, nn, "--group"))) {
+ skip_next = true;
+ continue;
+ }
+ if (next[0] == '-' ||
+ ((word_eq(word, n, "env") || word_eq(word, n, "busybox")) &&
+ strchr(next, '=') != NULL))
+ continue;
+ p = next;
+ break;
+ }
+ }
+ while (*p && isspace((unsigned char)*p))
+ p++;
+ return p;
+}
+
+int shell_policy(const char *command, const tool_limits *l, char **reason) {
+ for (size_t i = 0; i < l->shell_denylist.len; i++) {
+ const char *e = l->shell_denylist.items[i];
+ if (entry_matches(e, command) ||
+ entry_matches(e, unwrap_command(command))) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b, "command matches the denylist entry `%s`", e);
+ char *r = xbuf_steal(&b);
+ if (reason)
+ *reason = r;
+ else
+ free(r);
+ return SHELL_DENIED;
+ }
+ }
+ if (l->shell_allowlist.len > 0) {
+ bool any = false;
+ for (size_t i = 0; i < l->shell_allowlist.len; i++)
+ if (entry_matches(l->shell_allowlist.items[i], command) ||
+ entry_matches(l->shell_allowlist.items[i],
+ unwrap_command(command))) {
+ any = true;
+ break;
+ }
+ if (!any) {
+ xbuf list;
+ xbuf_init(&list);
+ for (size_t i = 0; i < l->shell_allowlist.len; i++) {
+ if (i)
+ xbuf_put(&list, ", ");
+ xbuf_put(&list, l->shell_allowlist.items[i]);
+ }
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b, "command not in the allowlist (%s)", list.data ? list.data : "");
+ xbuf_free(&list);
+ char *r = xbuf_steal(&b);
+ if (reason)
+ *reason = r;
+ else
+ free(r);
+ return SHELL_DENIED;
+ }
+ }
+ return SHELL_ALLOWED;
+}
+
+static bool word_eq(const char *w, size_t n, const char *s) {
+ size_t sn = strlen(s);
+ return n == sn && strncmp(w, s, n) == 0;
+}
+
+bool is_destructive(const char *command) {
+ /* This is deliberately conservative rather than a shell parser: syntax
+ * that can introduce or expand commands requires confirmation. */
+ for (const char *p = command; *p; p++) {
+ if (*p == ';' || *p == '|' || *p == '&' || *p == '\n' ||
+ *p == '`' || *p == '<' || *p == '>' || *p == '$')
+ return true;
+ }
+ /* split the executable and its first argument after common wrappers */
+ const char *p = unwrap_command(command);
+ while (*p && isspace((unsigned char)*p))
+ p++;
+ const char *w1 = p;
+ while (*p && !isspace((unsigned char)*p))
+ p++;
+ size_t w1len = (size_t)(p - w1);
+ while (*p && isspace((unsigned char)*p))
+ p++;
+ const char *w2 = p;
+ while (*p && !isspace((unsigned char)*p))
+ p++;
+ size_t w2len = (size_t)(p - w2);
+
+ if (word_eq(w1, w1len, "rm") || word_eq(w1, w1len, "rmdir") ||
+ word_eq(w1, w1len, "kill") || word_eq(w1, w1len, "killall") ||
+ word_eq(w1, w1len, "pkill") || word_eq(w1, w1len, "truncate") ||
+ word_eq(w1, w1len, "mv") || word_eq(w1, w1len, "chmod") ||
+ word_eq(w1, w1len, "chown") || word_eq(w1, w1len, "mkfs") ||
+ word_eq(w1, w1len, "fdisk") || word_eq(w1, w1len, "parted") ||
+ word_eq(w1, w1len, "mount") || word_eq(w1, w1len, "umount") ||
+ word_eq(w1, w1len, "swapon") || word_eq(w1, w1len, "swapoff") ||
+ word_eq(w1, w1len, "shutdown") || word_eq(w1, w1len, "reboot") ||
+ word_eq(w1, w1len, "poweroff") || word_eq(w1, w1len, "halt"))
+ return true;
+ if (word_eq(w1, w1len, "dd"))
+ return strstr(command, "of=") != NULL;
+ if (word_eq(w1, w1len, "git"))
+ return word_eq(w2, w2len, "reset") || word_eq(w2, w2len, "clean") ||
+ word_eq(w2, w2len, "checkout") || word_eq(w2, w2len, "rebase") ||
+ word_eq(w2, w2len, "push");
+ return false;
+}
+
+void set_confirm_hook(bool (*hook)(const char *, void *), void *ud) {
+ g_confirm_hook = hook;
+ g_confirm_ud = ud;
+}
+
+static bool confirm_command(const char *command) {
+ if (g_confirm_hook)
+ return g_confirm_hook(command, g_confirm_ud);
+ return false;
+}
+
+int shell_gate(const char *command, const tool_limits *l, char **err) {
+ char *reason = NULL;
+ if (shell_policy(command, l, &reason) == SHELL_DENIED) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b, "run_shell blocked: %s", reason);
+ free(reason);
+ if (err)
+ *err = xbuf_steal(&b);
+ return -1;
+ }
+ if (l->confirm_destructive && is_destructive(command) &&
+ !confirm_command(command)) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b, "user declined to run destructive command: %s", command);
+ char *r = xbuf_steal(&b);
+ if (err)
+ *err = r;
+ else
+ free(r);
+ return -1;
+ }
+ return 0;
+}
diff --git a/src/limits.h b/src/limits.h
new file mode 100644
index 0000000..14ec0e5
--- /dev/null
+++ b/src/limits.h
@@ -0,0 +1,40 @@
+#ifndef LLSH_LIMITS_H
+#define LLSH_LIMITS_H
+
+#include "util.h"
+
+/* Process-global tool limits + run_shell policy (mirrors limits.rs). */
+
+typedef struct {
+ unsigned long shell_timeout_secs;
+ unsigned long max_output_chars;
+ unsigned long max_file_bytes;
+ strlist shell_allowlist;
+ strlist shell_denylist;
+ bool confirm_destructive;
+ bool verbose;
+} tool_limits;
+
+void tool_limits_defaults(tool_limits *l);
+void tool_limits_set(const tool_limits *l);
+/* Read-only view of the current limits (no copy). The pointer is valid until
+ * the next tool_limits_set; do not store it across set calls. */
+const tool_limits *tool_limits_current(void);
+void tool_limits_get(tool_limits *out);
+void tool_limits_free(tool_limits *l);
+
+typedef enum {
+ SHELL_ALLOWED,
+ SHELL_DENIED,
+} shell_verdict;
+
+/* Returns 0 allowed, -1 denied (reason in **err, owned). */
+int shell_policy(const char *command, const tool_limits *l, char **reason);
+bool is_destructive(const char *command);
+
+void set_confirm_hook(bool (*hook)(const char *command, void *ud), void *ud);
+
+/* Full gate: policy, then destructive confirmation. 0 = proceed. */
+int shell_gate(const char *command, const tool_limits *l, char **err);
+
+#endif
diff --git a/src/llm.h b/src/llm.h
new file mode 100644
index 0000000..f0b68f0
--- /dev/null
+++ b/src/llm.h
@@ -0,0 +1,1722 @@
+/*
+ * llm.h -- header-only LLM provider protocol layer.
+ *
+ * Speaks OpenAI-compatible (any /chat/completions endpoint), Anthropic
+ * (/v1/messages), and Ollama (/api/chat). Pure protocol: request assembly,
+ * HTTP POST with stop-cancellation, SSE / NDJSON streaming with live delta
+ * emission, response and error-body parsing, Retry-After handling. No
+ * application state: messages arrive as borrowed `llm_message` arrays, tools
+ * as a pre-built `"tools":[...]` JSON fragment, and tool execution, session
+ * access, and emit formatting are supplied by the caller.
+ *
+ * Header-only: every symbol is `static inline`, so a translation unit that
+ * `#include`s this file gets a self-contained copy and there is nothing to
+ * link. Include it from exactly the TUs that drive a provider. Each TU's
+ * copies are independent.
+ *
+ * Dependencies: curl, cJSON, libc.
+ *
+ * API:
+ * llm_url / llm_auth / llm_build_request request side
+ * llm_post / llm_post_stream transport
+ * llm_parse_body / llm_parse_error /
+ * llm_header_retry_after response side
+ * llm_sse_acc_* / llm_sse_parse_line streaming accumulator
+ * llm_completion / llm_completion_free parsed result
+ */
+#ifndef LLM_H
+#define LLM_H
+
+#include <curl/curl.h>
+#include <cjson/cJSON.h>
+#include <ctype.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#ifdef __cplusplus
+#include <atomic>
+#endif
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Provider + connection */
+/* ------------------------------------------------------------------ */
+
+typedef enum {
+ LLM_OPENAI = 0,
+ LLM_ANTHROPIC,
+ LLM_OLLAMA,
+} llm_provider;
+
+/* Default cap on generated tokens for Anthropic requests, which require an
+ * explicit `max_tokens`. */
+#define LLM_DEFAULT_MAX_TOKENS 8192UL
+
+/* Total wall-clock budget for a single (possibly streaming) LLM request.
+ * Generous on purpose: long generations and slow cold-start servers. */
+#define LLM_REQUEST_TIMEOUT_SECS 600L
+
+/* One provider connection. Strings are borrowed for the call's lifetime.
+ * base_url NULL = provider default; api_key NULL = unauthenticated;
+ * ca_bundle NULL = curl's default CA path. */
+typedef struct {
+ llm_provider provider;
+ const char *model;
+ const char *base_url;
+ const char *api_key;
+ const char *ca_bundle;
+} llm_conn;
+
+/* Optional generation parameters. Every field has an "unset" value, so a
+ * zeroed struct (or NULL) produces a plain request:
+ * temperature < 0 = unset
+ * max_tokens 0 = unset
+ * reasoning_effort NULL = unset ("low" / "medium" / "high")
+ * thinking_budget 0 = unset (tokens; Anthropic extended thinking)
+ * response_schema NULL = unset (JSON Schema text, caller-validated) */
+typedef struct {
+ double temperature;
+ unsigned long max_tokens;
+ const char *reasoning_effort;
+ unsigned long thinking_budget;
+ const char *response_schema;
+} llm_params;
+
+static inline void llm_params_init(llm_params *p) {
+ memset(p, 0, sizeof *p);
+ p->temperature = -1.0;
+}
+
+/* Anthropic extended-thinking budget for a reasoning-effort label. */
+#define LLM_EFFORT_BUDGET_LOW 1024UL
+#define LLM_EFFORT_BUDGET_MEDIUM 4096UL
+#define LLM_EFFORT_BUDGET_HIGH 16384UL
+
+/* ------------------------------------------------------------------ */
+/* Wire message model (borrowed strings) */
+/* ------------------------------------------------------------------ */
+
+typedef enum {
+ LLM_ROLE_SYSTEM,
+ LLM_ROLE_USER,
+ LLM_ROLE_ASSISTANT,
+} llm_role;
+
+typedef enum {
+ LLM_TEXT, /* user/assistant text */
+ LLM_REASONING, /* assistant chain of thought (display only, not re-sent) */
+ LLM_TOOL_CALL, /* assistant: name, args (JSON text) */
+ LLM_TOOL_RESULT, /* user: call (tool-call id), name, text */
+} llm_part_kind;
+
+typedef struct {
+ llm_part_kind kind;
+ const char *text; /* LLM_TEXT / LLM_REASONING / LLM_TOOL_RESULT */
+ const char *name; /* LLM_TOOL_CALL / LLM_TOOL_RESULT */
+ const char *args; /* LLM_TOOL_CALL: JSON text */
+ const char *call; /* LLM_TOOL_RESULT: tool-call id (may be NULL) */
+} llm_part;
+
+typedef struct {
+ llm_role role;
+ llm_part *parts; /* borrowed; const from the consumer's view */
+ size_t part_count;
+} llm_message;
+
+/* Growable byte buffer (NUL-terminated on steal). */
+typedef struct llm_buf {
+ char *data;
+ size_t len;
+ size_t cap;
+} llm_buf;
+
+/* ------------------------------------------------------------------ */
+/* Parsed completion (owned strings) */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ char *text;
+ char *reasoning; /* thinking / reasoning_content, when the model returns it */
+ char **tc_id;
+ char **tc_name;
+ char **tc_args;
+ size_t tc_len;
+ unsigned long input_tokens;
+} llm_completion;
+
+/* Accumulates one streamed response. Text/reasoning arrive as deltas;
+ * tool calls may arrive in indexed chunks (OpenAI) or as whole blocks
+ * (Anthropic) or complete objects (Ollama NDJSON). */
+typedef struct {
+ struct llm_buf text;
+ struct llm_buf reasoning;
+ char **tc_id;
+ char **tc_name;
+ struct llm_buf *tc_args; /* growing argument fragments, one per call */
+ size_t tc_len;
+ unsigned long input_tokens;
+ bool done;
+ /* Anthropic event state: the `event:` line that the next `data:`
+ * belongs to, and the kind of the open content block. */
+ char *anth_event;
+ int anth_mode; /* 0 = text, 1 = thinking, 2 = tool */
+ size_t anth_tc;
+} llm_sse_acc;
+
+/* Live-delta emitter for streaming. Return 0 to continue, non-zero to
+ * abort the transfer (a user stop). */
+typedef enum {
+ LLM_EMIT_TEXT = 0,
+ LLM_EMIT_REASONING,
+} llm_emit_kind;
+
+typedef int (*llm_emit_cb)(llm_provider prov, llm_emit_kind kind,
+ const char *delta, void *user);
+
+/* ================================================================== */
+/* Implementation (static inline; active in every including TU) */
+/* ================================================================== */
+
+/* ------------------------------------------------------------------ */
+/* Allocation + small string utilities (self-contained) */
+/* ------------------------------------------------------------------ */
+
+static inline void *llm_xmalloc(size_t n) {
+ void *p = malloc(n ? n : 1);
+ if (!p) {
+ fprintf(stderr, "llm: out of memory\n");
+ abort();
+ }
+ return p;
+}
+
+static inline void *llm_xrealloc(void *p, size_t n) {
+ void *q = realloc(p, n ? n : 1);
+ if (!q) {
+ fprintf(stderr, "llm: out of memory\n");
+ abort();
+ }
+ return q;
+}
+
+static inline char *llm_strdup(const char *s) {
+ size_t n = s ? strlen(s) : 0;
+ char *p = (char *)llm_xmalloc(n + 1);
+ if (s)
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+static inline char *llm_strndup(const char *s, size_t n) {
+ char *p = (char *)llm_xmalloc(n + 1);
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+static inline char *llm_vsnprintf(const char *fmt, va_list ap) {
+ va_list ap2;
+ va_copy(ap2, ap);
+ int n = vsnprintf(NULL, 0, fmt, ap2);
+ va_end(ap2);
+ if (n < 0)
+ return llm_strdup("");
+ char *p = (char *)llm_xmalloc((size_t)n + 1);
+ vsnprintf(p, (size_t)n + 1, fmt, ap);
+ return p;
+}
+
+static inline char *llm_strfmt(const char *fmt, ...) {
+ va_list ap;
+ va_start(ap, fmt);
+ char *s = llm_vsnprintf(fmt, ap);
+ va_end(ap);
+ return s;
+}
+
+static inline bool llm_streq(const char *a, const char *b) {
+ return a && b && strcmp(a, b) == 0;
+}
+
+/* First case-insensitive occurrence of `needle` in `hay` (portable
+ * strcasestr). */
+static inline const char *llm_strcasestr(const char *hay, const char *needle) {
+ size_t nn = strlen(needle);
+ if (nn == 0)
+ return hay;
+ for (const char *p = hay; *p; p++) {
+ bool ok = true;
+ for (size_t i = 0; i < nn; i++) {
+ if (tolower((unsigned char)p[i]) !=
+ tolower((unsigned char)needle[i])) {
+ ok = false;
+ break;
+ }
+ }
+ if (ok)
+ return p;
+ }
+ return NULL;
+}
+
+static inline void llm_buf_init(llm_buf *b) {
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+static inline void llm_buf_free(llm_buf *b) {
+ free(b->data);
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+static inline void llm_buf_reserve(llm_buf *b, size_t extra) {
+ if (b->len + extra + 1 > b->cap) {
+ size_t cap = b->cap ? b->cap : 64;
+ while (cap < b->len + extra + 1)
+ cap *= 2;
+ b->data = (char *)llm_xrealloc(b->data, cap);
+ b->cap = cap;
+ }
+}
+
+static inline void llm_buf_putn(llm_buf *b, const char *s, size_t n) {
+ llm_buf_reserve(b, n);
+ memcpy(b->data + b->len, s, n);
+ b->len += n;
+ b->data[b->len] = '\0';
+}
+
+static inline void llm_buf_put(llm_buf *b, const char *s) {
+ if (s)
+ llm_buf_putn(b, s, strlen(s));
+}
+
+static inline void llm_buf_push(llm_buf *b, char c) {
+ llm_buf_reserve(b, 1);
+ b->data[b->len++] = c;
+ b->data[b->len] = '\0';
+}
+
+static inline char *llm_buf_steal(llm_buf *b) {
+ if (!b->data)
+ b->data = (char *)llm_xmalloc(1);
+ b->data[b->len] = '\0';
+ char *s = b->data;
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+ return s;
+}
+
+/* Small owned string list. */
+typedef struct {
+ char **items;
+ size_t len;
+ size_t cap;
+} llm_strlist;
+
+static inline void llm_sl_init(llm_strlist *l) {
+ l->items = NULL;
+ l->len = 0;
+ l->cap = 0;
+}
+
+static inline void llm_sl_free(llm_strlist *l) {
+ for (size_t i = 0; i < l->len; i++)
+ free(l->items[i]);
+ free(l->items);
+ llm_sl_init(l);
+}
+
+static inline void llm_sl_push_copy(llm_strlist *l, const char *s) {
+ if (l->len + 1 > l->cap) {
+ l->cap = l->cap ? l->cap * 2 : 8;
+ l->items =
+ (char **)llm_xrealloc(l->items, l->cap * sizeof(char *));
+ }
+ l->items[l->len++] = llm_strdup(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* Request side */
+/* ------------------------------------------------------------------ */
+
+static inline char *llm_url(const llm_conn *c) {
+ const char *def;
+ switch (c->provider) {
+ case LLM_ANTHROPIC:
+ def = "https://api.anthropic.com";
+ break;
+ case LLM_OLLAMA:
+ def = "http://localhost:11434";
+ break;
+ case LLM_OPENAI:
+ default:
+ def = "https://api.openai.com/v1";
+ break;
+ }
+ char *base = llm_strdup(c->base_url ? c->base_url : def);
+ size_t n = strlen(base);
+ while (n && base[n - 1] == '/')
+ base[--n] = '\0';
+ llm_buf u;
+ llm_buf_init(&u);
+ llm_buf_put(&u, base);
+ free(base);
+ switch (c->provider) {
+ case LLM_ANTHROPIC:
+ llm_buf_put(&u, "/v1/messages");
+ break;
+ case LLM_OLLAMA:
+ llm_buf_put(&u, "/api/chat");
+ break;
+ case LLM_OPENAI:
+ default:
+ llm_buf_put(&u, "/chat/completions");
+ break;
+ }
+ char *out = llm_buf_steal(&u);
+ llm_buf_free(&u);
+ return out;
+}
+
+static inline void llm_auth(const llm_conn *c, char **header, char **value) {
+ if (header)
+ *header = NULL;
+ if (value)
+ *value = NULL;
+ if (!c->api_key)
+ return;
+ switch (c->provider) {
+ case LLM_OPENAI:
+ if (header)
+ *header = llm_strdup("Authorization");
+ if (value)
+ *value = llm_strfmt("Bearer %s", c->api_key);
+ break;
+ case LLM_ANTHROPIC:
+ if (header)
+ *header = llm_strdup("x-api-key");
+ if (value)
+ *value = llm_strdup(c->api_key);
+ break;
+ case LLM_OLLAMA:
+ break;
+ }
+}
+
+/* Shared setup: cached-or-throwaway curl handle, the JSON + auth header
+ * list, and the common POST options. Returns the handle (or NULL with
+ * *err set) and the header list in *out_h (the caller frees it with
+ * curl_slist_free_all). */
+static inline CURL *llm_post_setup(const llm_conn *c, const char *url,
+ const char *body,
+ struct curl_slist **out_h, char **err,
+ CURL **cached) {
+ CURL *h = cached ? *cached : NULL;
+ if (!h) {
+ h = curl_easy_init();
+ if (h && cached)
+ *cached = h;
+ }
+ if (!h) {
+ if (err)
+ *err = llm_strdup("could not initialize curl");
+ return NULL;
+ }
+ struct curl_slist *hl = NULL;
+ hl = curl_slist_append(hl, "Content-Type: application/json");
+ char *ah = NULL, *av = NULL;
+ llm_auth(c, &ah, &av);
+ if (ah && av) {
+ llm_buf hb;
+ llm_buf_init(&hb);
+ char *combo = llm_strfmt("%s: %s", ah, av);
+ llm_buf_put(&hb, combo);
+ free(combo);
+ hl = curl_slist_append(hl, hb.data);
+ llm_buf_free(&hb);
+ free(ah);
+ free(av);
+ }
+ curl_easy_setopt(h, CURLOPT_HTTPHEADER, hl);
+ curl_easy_setopt(h, CURLOPT_URL, url);
+ curl_easy_setopt(h, CURLOPT_POST, 1L);
+ curl_easy_setopt(h, CURLOPT_POSTFIELDS, body);
+ curl_easy_setopt(h, CURLOPT_POSTFIELDSIZE, (long)strlen(body));
+ curl_easy_setopt(h, CURLOPT_TIMEOUT, LLM_REQUEST_TIMEOUT_SECS);
+ curl_easy_setopt(h, CURLOPT_NOSIGNAL, 1L);
+ if (c->ca_bundle)
+ curl_easy_setopt(h, CURLOPT_CAINFO, c->ca_bundle);
+ *out_h = hl;
+ return h;
+}
+
+typedef struct {
+ llm_buf body;
+ llm_buf headers;
+ std::atomic<bool> *stop;
+ bool response_too_large;
+} llm_post_ctx;
+
+static inline size_t llm_post_write(char *d, size_t s, size_t n, void *ud) {
+ llm_post_ctx *c = (llm_post_ctx *)ud;
+ const size_t cap = 8u * 1024u * 1024u;
+ if ((s && n > SIZE_MAX / s) || c->body.len > cap ||
+ s * n > cap - c->body.len) {
+ c->response_too_large = true;
+ return 0;
+ }
+ llm_buf_putn(&c->body, d, s * n);
+ return s * n;
+}
+
+static inline size_t llm_post_header(char *d, size_t s, size_t n, void *ud) {
+ llm_post_ctx *c = (llm_post_ctx *)ud;
+ llm_buf_putn(&c->headers, d, s * n);
+ return s * n;
+}
+
+static inline int llm_post_xfer(void *clientp, curl_off_t t, curl_off_t dn,
+ curl_off_t ut, curl_off_t un) {
+ (void)t;
+ (void)dn;
+ (void)ut;
+ (void)un;
+ llm_post_ctx *c = (llm_post_ctx *)clientp;
+ if (c->stop && c->stop->load())
+ return 1; /* abort the transfer */
+ return 0;
+}
+
+static inline int llm_post(const llm_conn *c, const char *url,
+ const char *body, long *status_out, char **body_out,
+ char **headers_out, char **err,
+ std::atomic<bool> *stop, CURL **cached) {
+ if (body_out)
+ *body_out = NULL;
+ if (headers_out)
+ *headers_out = NULL;
+ if (err)
+ *err = NULL;
+
+ llm_post_ctx ctx;
+ llm_buf_init(&ctx.body);
+ llm_buf_init(&ctx.headers);
+ ctx.stop = stop;
+ ctx.response_too_large = false;
+
+ struct curl_slist *h = NULL;
+ CURL *curl =
+ llm_post_setup(c, url, body, &h, err, cached);
+ if (!curl) {
+ llm_buf_free(&ctx.body);
+ llm_buf_free(&ctx.headers);
+ return -1;
+ }
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, llm_post_write);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx.body);
+ curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, llm_post_header);
+ curl_easy_setopt(curl, CURLOPT_HEADERDATA, &ctx);
+ curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
+ curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, llm_post_xfer);
+ curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &ctx);
+
+ CURLcode rc = curl_easy_perform(curl);
+ long code = 0;
+ curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code);
+ curl_slist_free_all(h);
+ char *body_str = llm_buf_steal(&ctx.body);
+ char *hdr_str = llm_buf_steal(&ctx.headers);
+ llm_buf_free(&ctx.body);
+ llm_buf_free(&ctx.headers);
+ if (!cached || *cached != curl)
+ curl_easy_cleanup(curl); /* throwaway handle; the cached one outlives us */
+
+ if (rc != CURLE_OK) {
+ if (err)
+ *err = llm_strdup(ctx.response_too_large
+ ? "LLM response exceeds 8 MiB limit"
+ : curl_easy_strerror(rc));
+ free(body_str);
+ free(hdr_str);
+ return -1;
+ }
+ if (status_out)
+ *status_out = code;
+ if (body_out)
+ *body_out = body_str;
+ else
+ free(body_str);
+ if (headers_out)
+ *headers_out = hdr_str;
+ else
+ free(hdr_str);
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Wire messages -> provider JSON */
+/* ------------------------------------------------------------------ */
+
+/* Recover the tool-call ids in order (they live on the tool results). */
+static inline void llm_collect_result_ids(const llm_message *msgs,
+ size_t n, llm_strlist *out) {
+ llm_sl_init(out);
+ for (size_t i = 0; i < n; i++) {
+ const llm_message *m = &msgs[i];
+ if (m->role != LLM_ROLE_USER)
+ continue;
+ for (size_t j = 0; j < m->part_count; j++) {
+ const llm_part *p = &m->parts[j];
+ if (p->kind == LLM_TOOL_RESULT)
+ llm_sl_push_copy(out, p->call ? p->call : "");
+ }
+ }
+}
+
+static inline char *llm_id_or(const llm_strlist *ids, size_t i) {
+ if (i < ids->len && ids->items[i] && ids->items[i][0])
+ return llm_strdup(ids->items[i]);
+ return llm_strfmt("call-%d", (int)(i + 1));
+}
+
+static inline cJSON *llm_openai_messages(const llm_message *msgs, size_t n) {
+ cJSON *arr = cJSON_CreateArray();
+ llm_strlist ids;
+ llm_collect_result_ids(msgs, n, &ids);
+ size_t call_idx = 0, result_idx = 0;
+ for (size_t i = 0; i < n; i++) {
+ const llm_message *m = &msgs[i];
+ if (m->role == LLM_ROLE_SYSTEM) {
+ const char *text = "";
+ if (m->part_count && m->parts[0].text)
+ text = m->parts[0].text;
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "role", "system");
+ cJSON_AddStringToObject(o, "content", text);
+ cJSON_AddItemToArray(arr, o);
+ } else if (m->role == LLM_ROLE_USER) {
+ for (size_t j = 0; j < m->part_count; j++) {
+ const llm_part *p = &m->parts[j];
+ if (p->kind == LLM_TEXT) {
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "role", "user");
+ cJSON_AddStringToObject(o, "content",
+ p->text ? p->text : "");
+ cJSON_AddItemToArray(arr, o);
+ } else if (p->kind == LLM_TOOL_RESULT) {
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "role", "tool");
+ char *id = llm_id_or(&ids, result_idx);
+ result_idx++;
+ cJSON_AddStringToObject(o, "tool_call_id", id);
+ free(id);
+ if (p->name)
+ cJSON_AddStringToObject(o, "name", p->name);
+ cJSON_AddStringToObject(o, "content",
+ p->text ? p->text : "");
+ cJSON_AddItemToArray(arr, o);
+ }
+ }
+ } else { /* assistant */
+ llm_buf txt;
+ llm_buf_init(&txt);
+ bool first = true;
+ cJSON *tcs = NULL;
+ for (size_t j = 0; j < m->part_count; j++) {
+ const llm_part *p = &m->parts[j];
+ if (p->kind == LLM_TEXT) {
+ if (!first)
+ llm_buf_push(&txt, '\n');
+ if (p->text)
+ llm_buf_put(&txt, p->text);
+ first = false;
+ } else if (p->kind == LLM_TOOL_CALL) {
+ if (!tcs)
+ tcs = cJSON_CreateArray();
+ cJSON *tcobj = cJSON_CreateObject();
+ char *id = llm_id_or(&ids, call_idx);
+ cJSON_AddStringToObject(tcobj, "id", id);
+ free(id);
+ cJSON_AddStringToObject(tcobj, "type", "function");
+ cJSON *fn = cJSON_AddObjectToObject(tcobj, "function");
+ cJSON_AddStringToObject(fn, "name", p->name ? p->name : "");
+ cJSON_AddStringToObject(fn, "arguments", p->args ? p->args : "{}");
+ cJSON_AddItemToArray(tcs, tcobj);
+ call_idx++;
+ }
+ }
+ char *t = llm_buf_steal(&txt);
+ llm_buf_free(&txt);
+ if (t[0] || tcs) {
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "role", "assistant");
+ if (t[0])
+ cJSON_AddStringToObject(o, "content", t);
+ else
+ cJSON_AddItemToObject(o, "content", cJSON_CreateNull());
+ if (tcs)
+ cJSON_AddItemToObject(o, "tool_calls", tcs);
+ cJSON_AddItemToArray(arr, o);
+ }
+ free(t);
+ }
+ }
+ llm_sl_free(&ids);
+ return arr;
+}
+
+static inline cJSON *llm_anthropic_messages(const llm_message *msgs,
+ size_t n) {
+ cJSON *arr = cJSON_CreateArray();
+ llm_strlist ids;
+ llm_collect_result_ids(msgs, n, &ids);
+ size_t call_idx = 0, result_idx = 0;
+ for (size_t i = 0; i < n; i++) {
+ const llm_message *m = &msgs[i];
+ if (m->role == LLM_ROLE_SYSTEM)
+ continue; /* folded into the top-level `system` */
+ cJSON *o = cJSON_CreateObject();
+ cJSON *content = cJSON_CreateArray();
+ if (m->role == LLM_ROLE_USER) {
+ cJSON_AddStringToObject(o, "role", "user");
+ for (size_t j = 0; j < m->part_count; j++) {
+ const llm_part *p = &m->parts[j];
+ if (p->kind == LLM_TEXT) {
+ cJSON *b = cJSON_CreateObject();
+ cJSON_AddStringToObject(b, "type", "text");
+ cJSON_AddStringToObject(b, "text", p->text ? p->text : "");
+ cJSON_AddItemToArray(content, b);
+ } else if (p->kind == LLM_TOOL_RESULT) {
+ cJSON *b = cJSON_CreateObject();
+ cJSON_AddStringToObject(b, "type", "tool_result");
+ char *id = llm_id_or(&ids, result_idx);
+ result_idx++;
+ cJSON_AddStringToObject(b, "tool_use_id", id);
+ free(id);
+ cJSON_AddStringToObject(b, "content", p->text ? p->text : "");
+ cJSON_AddItemToArray(content, b);
+ }
+ }
+ } else { /* assistant */
+ cJSON_AddStringToObject(o, "role", "assistant");
+ for (size_t j = 0; j < m->part_count; j++) {
+ const llm_part *p = &m->parts[j];
+ if (p->kind == LLM_TEXT) {
+ cJSON *b = cJSON_CreateObject();
+ cJSON_AddStringToObject(b, "type", "text");
+ cJSON_AddStringToObject(b, "text", p->text ? p->text : "");
+ cJSON_AddItemToArray(content, b);
+ } else if (p->kind == LLM_TOOL_CALL) {
+ cJSON *b = cJSON_CreateObject();
+ cJSON_AddStringToObject(b, "type", "tool_use");
+ char *id = llm_id_or(&ids, call_idx);
+ cJSON_AddStringToObject(b, "id", id);
+ free(id);
+ cJSON_AddStringToObject(b, "name", p->name ? p->name : "");
+ cJSON *inp = p->args ? cJSON_Parse(p->args) : NULL;
+ if (!inp)
+ inp = cJSON_CreateObject();
+ cJSON_AddItemToObject(b, "input", inp);
+ cJSON_AddItemToArray(content, b);
+ call_idx++;
+ }
+ }
+ }
+ cJSON_AddItemToObject(o, "content", content);
+ cJSON_AddItemToArray(arr, o);
+ }
+ llm_sl_free(&ids);
+ return arr;
+}
+
+/* ------------------------------------------------------------------ */
+/* Response parsing */
+/* ------------------------------------------------------------------ */
+
+static inline void llm_completion_free(llm_completion *c) {
+ free(c->text);
+ free(c->reasoning);
+ for (size_t i = 0; i < c->tc_len; i++) {
+ free(c->tc_id[i]);
+ free(c->tc_name[i]);
+ free(c->tc_args[i]);
+ }
+ free(c->tc_id);
+ free(c->tc_name);
+ free(c->tc_args);
+ c->text = NULL;
+ c->reasoning = NULL;
+ c->tc_id = c->tc_name = c->tc_args = NULL;
+ c->tc_len = 0;
+ c->input_tokens = 0;
+}
+
+static inline void llm_add_tool_call(llm_completion *c, char *id, char *name,
+ char *args) {
+ c->tc_id = (char **)llm_xrealloc(c->tc_id, (c->tc_len + 1) * sizeof *c->tc_id);
+ c->tc_name = (char **)llm_xrealloc(c->tc_name, (c->tc_len + 1) * sizeof *c->tc_name);
+ c->tc_args = (char **)llm_xrealloc(c->tc_args, (c->tc_len + 1) * sizeof *c->tc_args);
+ c->tc_id[c->tc_len] = id;
+ c->tc_name[c->tc_len] = name;
+ c->tc_args[c->tc_len] = args;
+ c->tc_len++;
+}
+
+static inline void llm_parse_openai(const cJSON *resp, llm_completion *c) {
+ const cJSON *choices = cJSON_GetObjectItem(resp, "choices");
+ if (cJSON_IsArray(choices) && choices->child) {
+ const cJSON *choice = choices->child;
+ const cJSON *msg = cJSON_GetObjectItem(choice, "message");
+ if (msg) {
+ const cJSON *ct = cJSON_GetObjectItem(msg, "content");
+ if (cJSON_IsString(ct) && ct->valuestring)
+ c->text = llm_strdup(ct->valuestring);
+ /* Reasoning models (o1/o3 and compatibles) return the chain of
+ * thought in `reasoning_content`; some servers use `reasoning`. */
+ const cJSON *rc = cJSON_GetObjectItem(msg, "reasoning_content");
+ if (!cJSON_IsString(rc) || !rc->valuestring)
+ rc = cJSON_GetObjectItem(msg, "reasoning");
+ if (cJSON_IsString(rc) && rc->valuestring && rc->valuestring[0])
+ c->reasoning = llm_strdup(rc->valuestring);
+ const cJSON *tcs = cJSON_GetObjectItem(msg, "tool_calls");
+ if (cJSON_IsArray(tcs)) {
+ const cJSON *tc = NULL;
+ cJSON_ArrayForEach(tc, tcs) {
+ const cJSON *fn = cJSON_GetObjectItem(tc, "function");
+ char *id = NULL, *name = NULL, *args = NULL;
+ const cJSON *cid = cJSON_GetObjectItem(tc, "id");
+ if (cJSON_IsString(cid) && cid->valuestring)
+ id = llm_strdup(cid->valuestring);
+ if (fn) {
+ const cJSON *cn = cJSON_GetObjectItem(fn, "name");
+ if (cJSON_IsString(cn) && cn->valuestring)
+ name = llm_strdup(cn->valuestring);
+ const cJSON *ca = cJSON_GetObjectItem(fn, "arguments");
+ if (cJSON_IsString(ca) && ca->valuestring)
+ args = llm_strdup(ca->valuestring);
+ else if (cJSON_IsObject(ca)) {
+ char *s = cJSON_PrintUnformatted(ca);
+ args = s ? s : llm_strdup("{}");
+ }
+ }
+ llm_add_tool_call(
+ c, id ? id : llm_strfmt("call-%d", (int)(c->tc_len + 1)),
+ name ? name : llm_strdup(""),
+ args ? args : llm_strdup("{}"));
+ }
+ }
+ }
+ }
+ const cJSON *usage = cJSON_GetObjectItem(resp, "usage");
+ if (usage) {
+ const cJSON *pt = cJSON_GetObjectItem(usage, "prompt_tokens");
+ if (cJSON_IsNumber(pt))
+ c->input_tokens = (unsigned long)pt->valuedouble;
+ }
+}
+
+static inline void llm_parse_anthropic(const cJSON *resp, llm_completion *c) {
+ const cJSON *content = cJSON_GetObjectItem(resp, "content");
+ if (cJSON_IsArray(content)) {
+ const cJSON *b = NULL;
+ cJSON_ArrayForEach(b, content) {
+ const cJSON *type = cJSON_GetObjectItem(b, "type");
+ const char *t = (cJSON_IsString(type) && type->valuestring)
+ ? type->valuestring
+ : "";
+ if (llm_streq(t, "text")) {
+ const cJSON *txt = cJSON_GetObjectItem(b, "text");
+ if (cJSON_IsString(txt) && txt->valuestring) {
+ free(c->text);
+ c->text = llm_strdup(txt->valuestring);
+ }
+ } else if (llm_streq(t, "thinking")) {
+ const cJSON *th = cJSON_GetObjectItem(b, "thinking");
+ if (cJSON_IsString(th) && th->valuestring && th->valuestring[0]) {
+ free(c->reasoning);
+ c->reasoning = llm_strdup(th->valuestring);
+ }
+ } else if (llm_streq(t, "tool_use")) {
+ char *id = NULL, *name = NULL, *args = NULL;
+ const cJSON *tid = cJSON_GetObjectItem(b, "id");
+ if (cJSON_IsString(tid) && tid->valuestring)
+ id = llm_strdup(tid->valuestring);
+ const cJSON *tn = cJSON_GetObjectItem(b, "name");
+ if (cJSON_IsString(tn) && tn->valuestring)
+ name = llm_strdup(tn->valuestring);
+ const cJSON *tin = cJSON_GetObjectItem(b, "input");
+ if (cJSON_IsObject(tin)) {
+ char *s = cJSON_PrintUnformatted(tin);
+ args = s ? s : llm_strdup("{}");
+ }
+ llm_add_tool_call(
+ c, id ? id : llm_strfmt("call-%d", (int)(c->tc_len + 1)),
+ name ? name : llm_strdup(""),
+ args ? args : llm_strdup("{}"));
+ }
+ }
+ }
+ const cJSON *usage = cJSON_GetObjectItem(resp, "usage");
+ if (usage) {
+ const cJSON *it = cJSON_GetObjectItem(usage, "input_tokens");
+ if (cJSON_IsNumber(it))
+ c->input_tokens = (unsigned long)it->valuedouble;
+ }
+}
+
+static inline void llm_parse_ollama(const cJSON *resp, llm_completion *c) {
+ const cJSON *msg = cJSON_GetObjectItem(resp, "message");
+ if (msg) {
+ const cJSON *ct = cJSON_GetObjectItem(msg, "content");
+ if (cJSON_IsString(ct) && ct->valuestring)
+ c->text = llm_strdup(ct->valuestring);
+ /* Thinking models (qwen3, deepseek-r1, …) return `message.thinking`. */
+ const cJSON *th = cJSON_GetObjectItem(msg, "thinking");
+ if (cJSON_IsString(th) && th->valuestring && th->valuestring[0])
+ c->reasoning = llm_strdup(th->valuestring);
+ const cJSON *tcs = cJSON_GetObjectItem(msg, "tool_calls");
+ if (cJSON_IsArray(tcs)) {
+ const cJSON *tc = NULL;
+ cJSON_ArrayForEach(tc, tcs) {
+ const cJSON *fn = cJSON_GetObjectItem(tc, "function");
+ const cJSON *id = cJSON_GetObjectItem(tc, "id");
+ char *cid = (cJSON_IsString(id) && id->valuestring)
+ ? llm_strdup(id->valuestring)
+ : llm_strfmt("call-%d", (int)(c->tc_len + 1));
+ char *name = llm_strdup(""), *args = llm_strdup("{}");
+ if (fn) {
+ const cJSON *cn = cJSON_GetObjectItem(fn, "name");
+ if (cJSON_IsString(cn) && cn->valuestring) {
+ free(name);
+ name = llm_strdup(cn->valuestring);
+ }
+ const cJSON *ca = cJSON_GetObjectItem(fn, "arguments");
+ if (cJSON_IsString(ca) && ca->valuestring) {
+ free(args);
+ args = llm_strdup(ca->valuestring);
+ } else if (cJSON_IsObject(ca)) {
+ char *s = cJSON_PrintUnformatted(ca);
+ if (s) {
+ free(args);
+ args = s;
+ }
+ }
+ }
+ llm_add_tool_call(c, cid, name, args);
+ }
+ }
+ }
+ const cJSON *pe = cJSON_GetObjectItem(resp, "prompt_eval_count");
+ if (cJSON_IsNumber(pe))
+ c->input_tokens = (unsigned long)pe->valuedouble;
+}
+
+static inline int llm_parse_body(const llm_conn *c, const char *body,
+ llm_completion *out) {
+ if (!body || !body[0])
+ return -1;
+ cJSON *resp = cJSON_Parse(body);
+ if (!resp)
+ return -1;
+ if (c->provider == LLM_ANTHROPIC)
+ llm_parse_anthropic(resp, out);
+ else if (c->provider == LLM_OLLAMA)
+ llm_parse_ollama(resp, out);
+ else
+ llm_parse_openai(resp, out);
+ cJSON_Delete(resp);
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Error body + Retry-After */
+/* ------------------------------------------------------------------ */
+
+static inline char *llm_parse_error(const char *body, long status) {
+ char *msg = NULL;
+ if (body && body[0]) {
+ cJSON *j = cJSON_Parse(body);
+ if (j) {
+ const cJSON *e = cJSON_GetObjectItem(j, "error");
+ const cJSON *em = cJSON_IsObject(e)
+ ? cJSON_GetObjectItem(e, "message")
+ : (cJSON_IsString(e) ? e : NULL);
+ if (cJSON_IsString(em) && em->valuestring)
+ msg = llm_strdup(em->valuestring);
+ if (!msg)
+ msg = llm_strdup("");
+ cJSON_Delete(j);
+ } else {
+ size_t cap = strlen(body);
+ if (cap > 500)
+ cap = 500;
+ msg = llm_strndup(body, cap);
+ }
+ }
+ if (!msg || !msg[0])
+ msg = llm_strfmt("HTTP %ld", status);
+ return msg;
+}
+
+static inline unsigned long llm_retry_after(const char *val) {
+ if (!val)
+ return 0;
+ char *end = NULL;
+ long v = strtol(val, &end, 10);
+ if (end != val && v >= 0 && v <= 300)
+ return (unsigned long)v;
+ /* HTTP-date (RFC 7231 §7.1.1.1): a GMT timestamp. Take the delta from
+ * now, clamped to the same [0, 300]s window as delta-seconds. */
+ struct tm t;
+ memset(&t, 0, sizeof t);
+ static const char *const fmts[] = {
+ "%a, %d %b %Y %H:%M:%S GMT", /* IMF-fixdate */
+ "%a, %d %b %Y %H:%M:%S %Z", /* tolerant tz name */
+ "%a %b %d %H:%M:%S %Y", /* obs-date / asctime */
+ };
+ for (size_t i = 0; i < sizeof fmts / sizeof *fmts; i++) {
+ struct tm tt = t;
+ if (strptime(val, fmts[i], &tt) != NULL) {
+ long delta = (long)(timegm(&tt) - time(NULL));
+ if (delta < 0)
+ delta = 0;
+ if (delta > 300)
+ delta = 300;
+ return (unsigned long)delta;
+ }
+ }
+ return 0;
+}
+
+static inline unsigned long llm_header_retry_after(const char *headers) {
+ if (!headers)
+ return 0;
+ const char *p = headers;
+ while ((p = llm_strcasestr(p, "retry-after")) != NULL) {
+ p += strlen("retry-after");
+ while (*p == ':' || *p == ' ' || *p == '\t')
+ p++;
+ /* find the value up to CRLF */
+ char *val = llm_strdup(p);
+ size_t n = strlen(val);
+ while (n && (val[n - 1] == '\r' || val[n - 1] == '\n'))
+ val[--n] = '\0';
+ unsigned long r = llm_retry_after(val);
+ free(val);
+ if (r)
+ return r;
+ p += 1;
+ }
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* SSE streaming */
+/* ------------------------------------------------------------------ */
+
+static inline void llm_sse_acc_init(llm_sse_acc *a, llm_provider prov) {
+ (void)prov;
+ memset(a, 0, sizeof *a);
+ llm_buf_init(&a->text);
+ llm_buf_init(&a->reasoning);
+}
+
+static inline void llm_sse_acc_free(llm_sse_acc *a) {
+ llm_buf_free(&a->text);
+ llm_buf_free(&a->reasoning);
+ for (size_t i = 0; i < a->tc_len; i++) {
+ free(a->tc_id[i]);
+ free(a->tc_name[i]);
+ llm_buf_free(&a->tc_args[i]);
+ }
+ free(a->tc_id);
+ free(a->tc_name);
+ free(a->tc_args);
+ free(a->anth_event);
+ memset(a, 0, sizeof *a);
+}
+
+static inline void llm_acc_push_tool(llm_sse_acc *a, char *id, char *name) {
+ a->tc_id = (char **)llm_xrealloc(a->tc_id, (a->tc_len + 1) * sizeof *a->tc_id);
+ a->tc_name =
+ (char **)llm_xrealloc(a->tc_name, (a->tc_len + 1) * sizeof *a->tc_name);
+ a->tc_args =
+ (llm_buf *)llm_xrealloc(a->tc_args, (a->tc_len + 1) * sizeof *a->tc_args);
+ a->tc_id[a->tc_len] = id;
+ a->tc_name[a->tc_len] = name;
+ llm_buf_init(&a->tc_args[a->tc_len]);
+ a->tc_len++;
+}
+
+/* The args buffer of the tool call at `idx` (creating the slot when the
+ * stream has not announced it yet). Fresh slots adopt `id`/`name`. */
+static inline llm_buf *llm_acc_tool_args(llm_sse_acc *a, size_t idx, char *id,
+ char *name) {
+ while (a->tc_len <= idx)
+ llm_acc_push_tool(a, NULL, NULL);
+ if (id) {
+ free(a->tc_id[idx]);
+ a->tc_id[idx] = id;
+ }
+ if (name) {
+ if (a->tc_name[idx] && a->tc_name[idx][0])
+ free(name);
+ else {
+ free(a->tc_name[idx]);
+ a->tc_name[idx] = name;
+ }
+ }
+ return &a->tc_args[idx];
+}
+
+static inline void llm_sse_parse_line(llm_provider prov, const char *line,
+ llm_sse_acc *a) {
+ const char *p = line;
+ while (*p == ' ' || *p == '\t')
+ p++;
+ if (!*p)
+ return;
+
+ if (prov == LLM_ANTHROPIC) {
+ if (strncmp(p, "event:", 6) == 0) {
+ const char *v = p + 6;
+ while (*v == ' ')
+ v++;
+ char *ev = llm_strdup(v);
+ size_t n = strlen(ev);
+ while (n && (ev[n - 1] == '\r' || ev[n - 1] == '\n'))
+ ev[--n] = '\0';
+ free(a->anth_event);
+ a->anth_event = ev;
+ return;
+ }
+ if (strncmp(p, "data:", 5) != 0)
+ return;
+ const char *v = p + 5;
+ while (*v == ' ')
+ v++;
+ const char *ev = a->anth_event ? a->anth_event : "";
+ /* message_stop arrives as `data: [DONE]` (not JSON). */
+ if (llm_streq(ev, "message_stop")) {
+ a->done = true;
+ return;
+ }
+ cJSON *j = cJSON_Parse(v);
+ if (!j)
+ return;
+ if (llm_streq(ev, "message_start")) {
+ const cJSON *m = cJSON_GetObjectItem(j, "message");
+ const cJSON *u = m ? cJSON_GetObjectItem(m, "usage") : NULL;
+ if (u) {
+ const cJSON *it = cJSON_GetObjectItem(u, "input_tokens");
+ if (cJSON_IsNumber(it))
+ a->input_tokens = (unsigned long)it->valuedouble;
+ }
+ } else if (llm_streq(ev, "content_block_start")) {
+ const cJSON *cb = cJSON_GetObjectItem(j, "content_block");
+ const cJSON *t = cb ? cJSON_GetObjectItem(cb, "type") : NULL;
+ if (cJSON_IsString(t) && t->valuestring) {
+ if (llm_streq(t->valuestring, "thinking")) {
+ a->anth_mode = 1;
+ } else if (llm_streq(t->valuestring, "tool_use")) {
+ char *id = NULL, *name = NULL;
+ const cJSON *tid = cJSON_GetObjectItem(cb, "id");
+ if (cJSON_IsString(tid) && tid->valuestring)
+ id = llm_strdup(tid->valuestring);
+ const cJSON *tn = cJSON_GetObjectItem(cb, "name");
+ if (cJSON_IsString(tn) && tn->valuestring)
+ name = llm_strdup(tn->valuestring);
+ llm_acc_push_tool(a, id, name);
+ a->anth_mode = 2;
+ a->anth_tc = a->tc_len - 1;
+ } else {
+ a->anth_mode = 0;
+ }
+ }
+ } else if (llm_streq(ev, "content_block_delta")) {
+ const cJSON *d = cJSON_GetObjectItem(j, "delta");
+ const cJSON *t = d ? cJSON_GetObjectItem(d, "type") : NULL;
+ if (cJSON_IsString(t) && t->valuestring) {
+ if (llm_streq(t->valuestring, "text_delta")) {
+ const cJSON *s = cJSON_GetObjectItem(d, "text");
+ if (cJSON_IsString(s) && s->valuestring)
+ llm_buf_put(&a->text, s->valuestring);
+ } else if (llm_streq(t->valuestring, "thinking_delta")) {
+ const cJSON *s = cJSON_GetObjectItem(d, "thinking");
+ if (cJSON_IsString(s) && s->valuestring)
+ llm_buf_put(&a->reasoning, s->valuestring);
+ } else if (llm_streq(t->valuestring, "input_json_delta")) {
+ const cJSON *s = cJSON_GetObjectItem(d, "partial_json");
+ if (cJSON_IsString(s) && s->valuestring && a->anth_mode == 2)
+ llm_buf_put(&a->tc_args[a->anth_tc], s->valuestring);
+ }
+ }
+ }
+ cJSON_Delete(j);
+ return;
+ }
+
+ if (prov == LLM_OLLAMA) {
+ cJSON *j = cJSON_Parse(p);
+ if (!j)
+ return;
+ const cJSON *msg = cJSON_GetObjectItem(j, "message");
+ if (msg) {
+ const cJSON *ct = cJSON_GetObjectItem(msg, "content");
+ if (cJSON_IsString(ct) && ct->valuestring && ct->valuestring[0])
+ llm_buf_put(&a->text, ct->valuestring);
+ const cJSON *th = cJSON_GetObjectItem(msg, "thinking");
+ if (cJSON_IsString(th) && th->valuestring && th->valuestring[0])
+ llm_buf_put(&a->reasoning, th->valuestring);
+ const cJSON *tcs = cJSON_GetObjectItem(msg, "tool_calls");
+ if (cJSON_IsArray(tcs)) {
+ /* Ollama carries no per-call index (and often no id), so the
+ * array position is the only stable key: map the i-th call to
+ * accumulator slot i so arguments streamed across chunks
+ * accumulate into one call instead of appending a duplicate
+ * slot per chunk (the OpenAI path does this via `index`). */
+ size_t pos = 0;
+ const cJSON *tc = tcs->child;
+ while (tc) {
+ char *id = NULL, *name = NULL;
+ const cJSON *cid = cJSON_GetObjectItem(tc, "id");
+ if (cJSON_IsString(cid) && cid->valuestring)
+ id = llm_strdup(cid->valuestring);
+ const cJSON *fn = cJSON_GetObjectItem(tc, "function");
+ if (fn) {
+ const cJSON *cn = cJSON_GetObjectItem(fn, "name");
+ if (cJSON_IsString(cn) && cn->valuestring)
+ name = llm_strdup(cn->valuestring);
+ }
+ llm_buf *ab = llm_acc_tool_args(a, pos, id, name);
+ if (fn) {
+ const cJSON *ca = cJSON_GetObjectItem(fn, "arguments");
+ if (cJSON_IsString(ca) && ca->valuestring)
+ llm_buf_put(ab, ca->valuestring);
+ else if (cJSON_IsObject(ca)) {
+ char *s = cJSON_PrintUnformatted(ca);
+ if (s) {
+ llm_buf_put(ab, s);
+ free(s);
+ }
+ }
+ }
+ tc = tc->next;
+ pos++;
+ }
+ }
+ }
+ const cJSON *pe = cJSON_GetObjectItem(j, "prompt_eval_count");
+ if (cJSON_IsNumber(pe))
+ a->input_tokens = (unsigned long)pe->valuedouble;
+ const cJSON *dn = cJSON_GetObjectItem(j, "done");
+ if (cJSON_IsBool(dn) && dn->valueint)
+ a->done = true;
+ cJSON_Delete(j);
+ return;
+ }
+
+ /* OpenAI-compatible SSE. */
+ if (strncmp(p, "data:", 5) != 0)
+ return;
+ const char *v = p + 5;
+ while (*v == ' ')
+ v++;
+ if (llm_streq(v, "[DONE]")) {
+ a->done = true;
+ return;
+ }
+ cJSON *j = cJSON_Parse(v);
+ if (!j)
+ return;
+ const cJSON *choices = cJSON_GetObjectItem(j, "choices");
+ if (cJSON_IsArray(choices) && choices->child) {
+ const cJSON *choice = choices->child;
+ const cJSON *delta = cJSON_GetObjectItem(choice, "delta");
+ if (delta) {
+ const cJSON *ct = cJSON_GetObjectItem(delta, "content");
+ if (cJSON_IsString(ct) && ct->valuestring && ct->valuestring[0])
+ llm_buf_put(&a->text, ct->valuestring);
+ const cJSON *rc = cJSON_GetObjectItem(delta, "reasoning_content");
+ if (!cJSON_IsString(rc) || !rc->valuestring)
+ rc = cJSON_GetObjectItem(delta, "reasoning");
+ if (cJSON_IsString(rc) && rc->valuestring && rc->valuestring[0])
+ llm_buf_put(&a->reasoning, rc->valuestring);
+ const cJSON *tcs = cJSON_GetObjectItem(delta, "tool_calls");
+ if (cJSON_IsArray(tcs)) {
+ const cJSON *tc = NULL;
+ cJSON_ArrayForEach(tc, tcs) {
+ size_t idx = 0;
+ const cJSON *ci = cJSON_GetObjectItem(tc, "index");
+ if (cJSON_IsNumber(ci))
+ idx = (size_t)ci->valuedouble;
+ char *id = NULL, *name = NULL;
+ const cJSON *cid = cJSON_GetObjectItem(tc, "id");
+ if (cJSON_IsString(cid) && cid->valuestring)
+ id = llm_strdup(cid->valuestring);
+ const cJSON *fn = cJSON_GetObjectItem(tc, "function");
+ if (fn) {
+ const cJSON *cn = cJSON_GetObjectItem(fn, "name");
+ if (cJSON_IsString(cn) && cn->valuestring)
+ name = llm_strdup(cn->valuestring);
+ }
+ llm_buf *ab = llm_acc_tool_args(a, idx, id, name);
+ if (fn) {
+ const cJSON *ca = cJSON_GetObjectItem(fn, "arguments");
+ if (cJSON_IsString(ca) && ca->valuestring)
+ llm_buf_put(ab, ca->valuestring);
+ else if (cJSON_IsObject(ca)) {
+ char *s = cJSON_PrintUnformatted(ca);
+ if (s) {
+ llm_buf_put(ab, s);
+ free(s);
+ }
+ }
+ }
+ }
+ }
+ const cJSON *fr = cJSON_GetObjectItem(choice, "finish_reason");
+ if (cJSON_IsString(fr) && fr->valuestring && fr->valuestring[0])
+ a->done = true;
+ }
+ }
+ const cJSON *usage = cJSON_GetObjectItem(j, "usage");
+ if (usage) {
+ const cJSON *pt = cJSON_GetObjectItem(usage, "prompt_tokens");
+ if (cJSON_IsNumber(pt))
+ a->input_tokens = (unsigned long)pt->valuedouble;
+ }
+ cJSON_Delete(j);
+}
+
+static inline bool llm_sse_acc_had_content(const llm_sse_acc *a) {
+ return a->text.len > 0 || a->reasoning.len > 0 || a->tc_len > 0 || a->done;
+}
+
+/* Materialize the accumulated stream into `c` (ownership transfers). */
+static inline void llm_sse_acc_into(llm_sse_acc *a, llm_completion *c) {
+ /* Ownership transfers to `c` when non-empty; only free the buffer when
+ * `c` did not take it (empty). Freeing unconditionally would dangle the
+ * pointer `c` now owns -> double-free in llm_completion_free. */
+ char *t = llm_buf_steal(&a->text);
+ if (t && t[0])
+ c->text = t;
+ else
+ free(t);
+ char *r = llm_buf_steal(&a->reasoning);
+ if (r && r[0])
+ c->reasoning = r;
+ else
+ free(r);
+ for (size_t i = 0; i < a->tc_len; i++) {
+ char *args = llm_buf_steal(&a->tc_args[i]);
+ char *id = a->tc_id[i] ? a->tc_id[i]
+ : llm_strfmt("call-%d", (int)(i + 1));
+ char *name = a->tc_name[i] ? a->tc_name[i] : llm_strdup("");
+ if (!args || !args[0]) {
+ free(args);
+ args = llm_strdup("{}");
+ }
+ llm_add_tool_call(c, id, name, args);
+ a->tc_id[i] = a->tc_name[i] = NULL;
+ }
+ c->input_tokens = a->input_tokens;
+ a->input_tokens = 0;
+}
+
+/* Write-callback state for a streaming POST. */
+typedef struct {
+ llm_buf body;
+ llm_buf headers;
+ llm_buf linebuf; /* bytes not yet terminated by a newline */
+ llm_sse_acc *acc;
+ llm_provider prov;
+ llm_emit_cb emit;
+ void *user;
+ std::atomic<bool> *stop;
+ bool stopped; /* set when the transfer should abort */
+ bool response_too_large;
+ size_t emitted_text;
+ size_t emitted_reasoning;
+} llm_stream_ctx;
+
+static inline size_t llm_stream_write(char *d, size_t s, size_t n, void *ud) {
+ llm_stream_ctx *c = (llm_stream_ctx *)ud;
+ const size_t cap = 8u * 1024u * 1024u;
+ if ((s && n > SIZE_MAX / s) || c->body.len > cap ||
+ s * n > cap - c->body.len || c->linebuf.len > cap - s * n) {
+ c->response_too_large = true;
+ return 0;
+ }
+ llm_buf_putn(&c->body, d, s * n);
+ llm_buf_putn(&c->linebuf, d, s * n);
+ for (;;) {
+ char *nl = (char *)memchr(c->linebuf.data, '\n', c->linebuf.len);
+ if (!nl)
+ break;
+ size_t lpos = (size_t)(nl - c->linebuf.data);
+ if (lpos > 0 && c->linebuf.data[lpos - 1] == '\r')
+ lpos--;
+ char *line = llm_strndup(c->linebuf.data, lpos);
+ size_t rest = c->linebuf.len - lpos - 1;
+ memmove(c->linebuf.data, c->linebuf.data + lpos + 1, rest);
+ c->linebuf.len = rest;
+ llm_sse_parse_line(c->prov, line, c->acc);
+ free(line);
+
+ if (c->acc->text.len > c->emitted_text) {
+ char *delta = llm_strndup(c->acc->text.data + c->emitted_text,
+ c->acc->text.len - c->emitted_text);
+ c->emitted_text = c->acc->text.len;
+ if (c->emit &&
+ c->emit(c->prov, LLM_EMIT_TEXT, delta, c->user) != 0)
+ c->stopped = true;
+ free(delta);
+ }
+ if (!c->stopped && c->acc->reasoning.len > c->emitted_reasoning) {
+ char *delta = llm_strndup(c->acc->reasoning.data +
+ c->emitted_reasoning,
+ c->acc->reasoning.len -
+ c->emitted_reasoning);
+ c->emitted_reasoning = c->acc->reasoning.len;
+ if (c->emit &&
+ c->emit(c->prov, LLM_EMIT_REASONING, delta, c->user) != 0)
+ c->stopped = true;
+ free(delta);
+ }
+ if (c->stopped || (c->stop && c->stop->load())) {
+ c->stopped = true;
+ break;
+ }
+ }
+ if (c->stopped)
+ return 0; /* short write: abort the transfer */
+ return s * n;
+}
+
+static inline size_t llm_stream_header(char *d, size_t s, size_t n, void *ud) {
+ llm_stream_ctx *c = (llm_stream_ctx *)ud;
+ llm_buf_putn(&c->headers, d, s * n);
+ return s * n;
+}
+
+static inline int llm_stream_xfer(void *clientp, curl_off_t t, curl_off_t dn,
+ curl_off_t ut, curl_off_t un) {
+ (void)t;
+ (void)dn;
+ (void)ut;
+ (void)un;
+ llm_stream_ctx *c = (llm_stream_ctx *)clientp;
+ if (c->stop && c->stop->load())
+ return 1;
+ return 0;
+}
+
+static inline int llm_post_stream(const llm_conn *c, const char *url,
+ const char *body, long *status_out,
+ char **body_out, char **headers_out,
+ llm_sse_acc *acc, llm_emit_cb emit, void *user,
+ std::atomic<bool> *stop, bool *stopped,
+ char **err, CURL **cached) {
+ if (body_out)
+ *body_out = NULL;
+ if (headers_out)
+ *headers_out = NULL;
+ if (stopped)
+ *stopped = false;
+ if (err)
+ *err = NULL;
+ if (status_out)
+ *status_out = 0;
+
+ llm_stream_ctx ctx;
+ memset(&ctx, 0, sizeof ctx);
+ llm_buf_init(&ctx.body);
+ llm_buf_init(&ctx.headers);
+ llm_buf_init(&ctx.linebuf);
+ ctx.acc = acc;
+ ctx.prov = c->provider;
+ ctx.emit = emit;
+ ctx.user = user;
+ ctx.stop = stop;
+
+ struct curl_slist *h = NULL;
+ CURL *curl = llm_post_setup(c, url, body, &h, err, cached);
+ if (!curl) {
+ llm_buf_free(&ctx.body);
+ llm_buf_free(&ctx.headers);
+ llm_buf_free(&ctx.linebuf);
+ return -1;
+ }
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, llm_stream_write);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx);
+ curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, llm_stream_header);
+ curl_easy_setopt(curl, CURLOPT_HEADERDATA, &ctx);
+ curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
+ curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, llm_stream_xfer);
+ curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &ctx);
+
+ CURLcode rc = curl_easy_perform(curl);
+ long code = 0;
+ curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code);
+ curl_slist_free_all(h);
+ char *body_str = llm_buf_steal(&ctx.body);
+ char *hdr_str = llm_buf_steal(&ctx.headers);
+ llm_buf_free(&ctx.body);
+ llm_buf_free(&ctx.headers);
+ llm_buf_free(&ctx.linebuf);
+ if (!cached || *cached != curl)
+ curl_easy_cleanup(curl);
+
+ if (ctx.stopped) {
+ if (stopped)
+ *stopped = true;
+ free(body_str);
+ free(hdr_str);
+ return 0;
+ }
+ if (rc != CURLE_OK) {
+ if (err)
+ *err = llm_strdup(ctx.response_too_large
+ ? "LLM streaming response exceeds 8 MiB limit"
+ : curl_easy_strerror(rc));
+ free(body_str);
+ free(hdr_str);
+ return -1;
+ }
+ if (status_out)
+ *status_out = code;
+ if (body_out)
+ *body_out = body_str;
+ else
+ free(body_str);
+ if (headers_out)
+ *headers_out = hdr_str;
+ else
+ free(hdr_str);
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Request assembly */
+/* ------------------------------------------------------------------ */
+
+/* JSON-encode an arbitrary string (NULL-safe). */
+static inline char *llm_json_str(const char *s) {
+ cJSON *j = cJSON_CreateString(s ? s : "");
+ char *r = cJSON_PrintUnformatted(j);
+ cJSON_Delete(j);
+ return r ? r : llm_strdup("\"\"");
+}
+
+/* OpenAI-compatible body (OpenAI + Ollama). `top_level_system` routes the
+ * preamble to a top-level `system` field (Ollama) instead of a leading
+ * system message (OpenAI). `p` may be NULL. */
+static inline void llm_emit_openai_body(llm_buf *req, const llm_conn *c,
+ const char *preamble,
+ const llm_message *msgs, size_t n,
+ const char *tools_json,
+ bool top_level_system, bool stream,
+ const llm_params *p) {
+ llm_buf_put(req, "{\"model\":");
+ char *model = llm_json_str(c->model);
+ llm_buf_put(req, model);
+ free(model);
+ llm_buf_put(req, ",\"stream\":");
+ llm_buf_put(req, stream ? "true" : "false");
+ llm_buf_push(req, ',');
+ if (top_level_system && preamble && preamble[0]) {
+ char *ps = llm_json_str(preamble);
+ llm_buf_put(req, "\"system\":");
+ llm_buf_put(req, ps);
+ free(ps);
+ llm_buf_push(req, ',');
+ }
+ llm_buf_put(req, "\"messages\":");
+ cJSON *om = llm_openai_messages(msgs, n);
+ if (!top_level_system && preamble && preamble[0]) {
+ /* OpenAI-compatible: the preamble becomes a leading system message. */
+ cJSON *sys = cJSON_CreateObject();
+ cJSON_AddStringToObject(sys, "role", "system");
+ cJSON_AddStringToObject(sys, "content", preamble);
+ cJSON_InsertItemInArray(om, 0, sys);
+ }
+ char *oms = cJSON_PrintUnformatted(om);
+ llm_buf_put(req, oms ? oms : "[]");
+ free(oms);
+ cJSON_Delete(om);
+ if (tools_json) {
+ llm_buf_push(req, ',');
+ llm_buf_put(req, tools_json);
+ }
+ if (p) {
+ if (top_level_system) {
+ /* Ollama: sampling knobs nest under `options`. */
+ if (p->temperature >= 0.0 || p->max_tokens > 0) {
+ llm_buf_put(req, ",\"options\":{");
+ bool first = true;
+ if (p->temperature >= 0.0) {
+ char *t = llm_strfmt("\"temperature\":%.6g",
+ p->temperature);
+ llm_buf_put(req, t);
+ free(t);
+ first = false;
+ }
+ if (p->max_tokens > 0) {
+ if (!first)
+ llm_buf_push(req, ',');
+ char *v = llm_strfmt("\"num_predict\":%lu",
+ p->max_tokens);
+ llm_buf_put(req, v);
+ free(v);
+ }
+ llm_buf_push(req, '}');
+ }
+ if (p->reasoning_effort)
+ llm_buf_put(req, ",\"think\":true");
+ if (p->response_schema && p->response_schema[0]) {
+ llm_buf_put(req, ",\"format\":");
+ llm_buf_put(req, p->response_schema);
+ }
+ } else {
+ if (p->temperature >= 0.0) {
+ char *t = llm_strfmt(",\"temperature\":%.6g", p->temperature);
+ llm_buf_put(req, t);
+ free(t);
+ }
+ if (p->max_tokens > 0) {
+ char *v = llm_strfmt(",\"max_tokens\":%lu", p->max_tokens);
+ llm_buf_put(req, v);
+ free(v);
+ }
+ if (p->reasoning_effort) {
+ char *e = llm_strfmt(",\"reasoning_effort\":\"%s\"",
+ p->reasoning_effort);
+ llm_buf_put(req, e);
+ free(e);
+ }
+ if (p->response_schema && p->response_schema[0]) {
+ llm_buf_put(req,
+ ",\"response_format\":{\"type\":\"json_schema\","
+ "\"json_schema\":{\"name\":\"response\","
+ "\"schema\":");
+ llm_buf_put(req, p->response_schema);
+ llm_buf_put(req, "}}");
+ }
+ }
+ }
+ llm_buf_push(req, '}');
+}
+
+/* Assemble a request body. `p` may be NULL for a plain request. */
+static inline char *llm_build_request_ex(const llm_conn *c,
+ const char *preamble,
+ const llm_message *msgs, size_t n,
+ const char *tools_json, bool stream,
+ const llm_params *p) {
+ llm_buf req;
+ llm_buf_init(&req);
+ if (c->provider == LLM_ANTHROPIC) {
+ unsigned long budget = p ? p->thinking_budget : 0;
+ if (budget == 0 && p && p->reasoning_effort) {
+ if (llm_streq(p->reasoning_effort, "low"))
+ budget = LLM_EFFORT_BUDGET_LOW;
+ else if (llm_streq(p->reasoning_effort, "medium"))
+ budget = LLM_EFFORT_BUDGET_MEDIUM;
+ else
+ budget = LLM_EFFORT_BUDGET_HIGH;
+ }
+ unsigned long mt = LLM_DEFAULT_MAX_TOKENS;
+ if (p && p->max_tokens > 0)
+ mt = p->max_tokens;
+ if (budget && mt <= budget)
+ mt = budget + 1024;
+ llm_buf_put(&req, "{\"model\":");
+ char *model = llm_json_str(c->model);
+ llm_buf_put(&req, model);
+ free(model);
+ {
+ char *mtb = llm_strfmt(",\"max_tokens\":%lu,\"stream\":", mt);
+ llm_buf_put(&req, mtb);
+ free(mtb);
+ }
+ llm_buf_put(&req, stream ? "true" : "false");
+ if (budget) {
+ /* Extended thinking; Anthropic pins temperature to 1 with it. */
+ char *th = llm_strfmt(
+ ",\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":%lu}",
+ budget);
+ llm_buf_put(&req, th);
+ free(th);
+ } else if (p && p->temperature >= 0.0) {
+ char *t = llm_strfmt(",\"temperature\":%.6g", p->temperature);
+ llm_buf_put(&req, t);
+ free(t);
+ }
+ llm_buf_push(&req, ',');
+ const char *sys = preamble;
+ char *schema_sys = NULL;
+ if (p && p->response_schema && p->response_schema[0]) {
+ /* Anthropic has no response_format: steer via the system prompt. */
+ schema_sys = llm_strfmt(
+ "%s\n\nRespond with a single JSON object that matches this "
+ "JSON Schema. Output nothing else.\n%s",
+ preamble ? preamble : "", p->response_schema);
+ sys = schema_sys;
+ }
+ if (sys && sys[0]) {
+ char *ps = llm_json_str(sys);
+ llm_buf_put(&req, "\"system\":");
+ llm_buf_put(&req, ps);
+ free(ps);
+ llm_buf_push(&req, ',');
+ }
+ free(schema_sys);
+ llm_buf_put(&req, "\"messages\":");
+ cJSON *m = llm_anthropic_messages(msgs, n);
+ char *ms = cJSON_PrintUnformatted(m);
+ llm_buf_put(&req, ms ? ms : "[]");
+ free(ms);
+ cJSON_Delete(m);
+ if (tools_json) {
+ llm_buf_push(&req, ',');
+ llm_buf_put(&req, tools_json);
+ }
+ llm_buf_push(&req, '}');
+ } else if (c->provider == LLM_OLLAMA) {
+ /* Ollama takes the system prompt as a top-level `system` field. */
+ llm_emit_openai_body(&req, c, preamble, msgs, n, tools_json, true,
+ stream, p);
+ } else {
+ llm_emit_openai_body(&req, c, preamble, msgs, n, tools_json, false,
+ stream, p);
+ }
+ char *out = llm_buf_steal(&req);
+ llm_buf_free(&req);
+ return out;
+}
+
+static inline char *llm_build_request(const llm_conn *c, const char *preamble,
+ const llm_message *msgs, size_t n,
+ const char *tools_json, bool stream) {
+ return llm_build_request_ex(c, preamble, msgs, n, tools_json, stream, NULL);
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* LLM_H */
diff --git a/src/main.cpp b/src/main.cpp
new file mode 100644
index 0000000..b1247ee
--- /dev/null
+++ b/src/main.cpp
@@ -0,0 +1,1902 @@
+/* llsh — a terminal LLM agent harness (C port). */
+
+#include <ctype.h>
+#include <errno.h>
+#include <atomic>
+#include <pthread.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <unistd.h>
+
+#include "escape.h"
+#include "provider.h"
+#include "agent.h"
+#include "compact.h"
+#include "config.h"
+#include "dotagents.h"
+#include "goal.h"
+#include "input.h"
+#include "limits.h"
+#include "mcp.h"
+#include "message.h"
+#include "session.h"
+#include "shell.h"
+#include "tools.h"
+#include "verbose.h"
+
+/* ------------------------------------------------------------------ */
+/* Terminal escapes (from escape.h; faint `2m` has no escape.h macro) */
+/* ------------------------------------------------------------------ */
+#define DIM ESC_DIM
+#define BOLD_C ESC_BOLD ESC_COLOR_CYAN
+#define RED ESC_COLOR_RED
+#define YELLOW ESC_COLOR_YELLOW
+#define RESET ESC_RESET
+#define CHAT_PROMPT BOLD_C "\xE2\x80\xBA" RESET " "
+
+/* ------------------------------------------------------------------ */
+/* Output helpers */
+/* ------------------------------------------------------------------ */
+
+static void out_str(const char *s) {
+ if (!s)
+ return;
+ fwrite(s, 1, strlen(s), stdout);
+ fflush(stdout);
+}
+
+static void out_fmt(const char *fmt, ...) {
+ va_list ap;
+ va_start(ap, fmt);
+ char *s = xvsnprintf(fmt, ap);
+ va_end(ap);
+ out_str(s);
+ free(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* Emit callback (model output -> terminal) */
+/* ------------------------------------------------------------------ */
+
+/* ------------------------------------------------------------------ */
+/* Typewriter pacer (ported from ../llsh src/session.rs) */
+/* */
+/* llm.h already streams: provider_stream calls the sess_emit_cb for */
+/* each TEXT/REASONING delta as it arrives. The pacer consumes those */
+/* emits (via emit_cb) into a queue and reveals them at an adaptive */
+/* pace, so a token burst -- or a non-streaming provider that returns */
+/* the whole body at once -- renders as a smooth typewriter instead of a */
+/* single dump. A verbatim/tool line flushes the buffered prose first. */
+/* ------------------------------------------------------------------ */
+
+enum { PSTYLE_NORMAL, PSTYLE_DIM };
+enum { PSEG_TEXT, PSEG_VERBATIM };
+
+/* Wake every TICK_MS; reveal ~backlog/SMOOTH_MS of the backlog each tick so
+ * the reveal tracks the model's speed, clamped to [MIN,MAX] chars/tick. */
+#define PACER_TICK_MS 16
+#define PACER_SMOOTH_MS 1000
+#define PACER_MIN 2
+#define PACER_MAX 24
+
+typedef struct {
+ int kind; /* PSEG_TEXT or PSEG_VERBATIM */
+ int style; /* PSTYLE_* (TEXT only) */
+ char *text; /* owned */
+ size_t pos; /* revealed bytes (TEXT only) */
+} pseg;
+
+typedef struct {
+ pseg *items;
+ size_t n, cap;
+ bool finished;
+ bool abort;
+ pthread_t th;
+ pthread_mutex_t mu;
+ pthread_cond_t cv;
+} pacer;
+
+static void *pacer_run(void *arg);
+
+static pacer *pacer_new(void) {
+ pacer *p = (pacer *) xmalloc(sizeof *p);
+ memset(p, 0, sizeof *p);
+ pthread_mutex_init(&p->mu, NULL);
+ /* Use a monotonic clock so the tick timeout is immune to wall-clock
+ * adjustments (NTP steps); pthread_cond_timedwait must use the same
+ * clock as the absolute timeout we compute in pacer_run. */
+ pthread_condattr_t ca;
+ pthread_condattr_init(&ca);
+ pthread_condattr_setclock(&ca, CLOCK_MONOTONIC);
+ pthread_cond_init(&p->cv, &ca);
+ pthread_condattr_destroy(&ca);
+ return p;
+}
+
+static void pacer_free(pacer *p) {
+ pthread_mutex_lock(&p->mu);
+ for (size_t i = 0; i < p->n; i++)
+ free(p->items[i].text);
+ free(p->items);
+ p->n = 0;
+ pthread_mutex_unlock(&p->mu);
+ pthread_mutex_destroy(&p->mu);
+ pthread_cond_destroy(&p->cv);
+ free(p);
+}
+
+static void pacer_shift(pacer *p) {
+ memmove(p->items, p->items + 1, (p->n - 1) * sizeof *p->items);
+ p->n--;
+}
+
+/* Producer side (called from the turn worker via emit_cb). */
+static void pacer_push(pacer *p, int kind, int style, char *owned) {
+ pthread_mutex_lock(&p->mu);
+ if (p->n == p->cap) {
+ p->cap = p->cap ? p->cap * 2 : 8;
+ p->items = (pseg *) xrealloc(p->items, p->cap * sizeof *p->items);
+ }
+ p->items[p->n].kind = kind;
+ p->items[p->n].style = style;
+ p->items[p->n].text = owned;
+ p->items[p->n].pos = 0;
+ p->n++;
+ /* No condvar signal: a push must not wake the consumer. Reveal is strictly
+ * tick-paced (see pacer_run); waking here would dump MAX chars per arriving
+ * delta instead of spreading it across the smoothing window. */
+ pthread_mutex_unlock(&p->mu);
+}
+
+static void pacer_emit_text(pacer *p, int style, const char *text) {
+ if (!text || !text[0])
+ return;
+ pacer_push(p, PSEG_TEXT, style, xstrdup(text));
+}
+
+static void pacer_emit_verbatim(pacer *p, const char *text) {
+ if (!text)
+ return;
+ pacer_push(p, PSEG_VERBATIM, PSTYLE_NORMAL, xstrdup(text));
+}
+
+static void pacer_finish(pacer *p) {
+ pthread_mutex_lock(&p->mu);
+ p->finished = true;
+ pthread_cond_broadcast(&p->cv);
+ pthread_mutex_unlock(&p->mu);
+}
+
+static void pacer_abort(pacer *p) {
+ pthread_mutex_lock(&p->mu);
+ p->abort = true;
+ pthread_cond_broadcast(&p->cv);
+ pthread_mutex_unlock(&p->mu);
+}
+
+static void pacer_start(pacer *p) {
+ pthread_create(&p->th, NULL, pacer_run, p);
+}
+
+static const char *pstyle_transition(int open, int to) {
+ if (open == PSTYLE_NORMAL && to == PSTYLE_DIM)
+ return DIM;
+ if (open == PSTYLE_DIM && to == PSTYLE_NORMAL)
+ return RESET;
+ return "";
+}
+
+static size_t pacer_backlog(const pacer *p) {
+ size_t b = 0;
+ for (size_t i = 0; i < p->n; i++)
+ if (p->items[i].kind == PSEG_TEXT)
+ b += strlen(p->items[i].text) - p->items[i].pos;
+ return b;
+}
+
+static size_t pacer_reveal_count(const pacer *p) {
+ size_t backlog = pacer_backlog(p);
+ if (backlog == 0)
+ return 0;
+ size_t prop = backlog * PACER_TICK_MS / PACER_SMOOTH_MS;
+ if (prop < PACER_MIN)
+ prop = PACER_MIN;
+ if (prop > PACER_MAX)
+ prop = PACER_MAX;
+ return prop;
+}
+
+/* Reveal up to `budget` chars of TEXT from the front, writing style
+ * transitions. Caller holds p->mu. */
+static void pacer_drain_text(pacer *p, int *open, size_t budget) {
+ size_t remaining = budget;
+ xbuf out;
+ xbuf_init(&out);
+ while (remaining > 0 && p->n > 0) {
+ pseg *s = &p->items[0];
+ if (s->kind != PSEG_TEXT)
+ break;
+ size_t len = strlen(s->text);
+ size_t left = len - s->pos;
+ if (left == 0) {
+ free(s->text);
+ pacer_shift(p);
+ continue;
+ }
+ size_t take = remaining < left ? remaining : left;
+ if (*open == PSTYLE_DIM && s->style == PSTYLE_NORMAL) {
+ xbuf_put(&out, RESET "\n\n");
+ } else {
+ xbuf_put(&out, pstyle_transition(*open, s->style));
+ }
+ *open = s->style;
+ xbuf_putn(&out, s->text + s->pos, take);
+ s->pos += take;
+ remaining -= take;
+ if (s->pos >= len) {
+ free(s->text);
+ pacer_shift(p);
+ }
+ }
+ if (out.len)
+ out_str(out.data);
+ xbuf_free(&out);
+}
+
+/* Consumer thread: reveal the queue at the adaptive pace, write verbatims
+ * (flushing preceding prose), and shut down on finish/abort. */
+static void *pacer_run(void *arg) {
+ pacer *p = (pacer *) arg;
+ int open = PSTYLE_NORMAL;
+ for (;;) {
+ // Tick: wait up to PACER_TICK_MS, or wake early on a new emit/control.
+ pthread_mutex_lock(&p->mu);
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ ts.tv_nsec += (long)PACER_TICK_MS * 1000000L;
+ if (ts.tv_nsec >= 1000000000L) {
+ ts.tv_sec += 1;
+ ts.tv_nsec -= 1000000000L;
+ }
+ pthread_cond_timedwait(&p->cv, &p->mu, &ts);
+ pthread_mutex_unlock(&p->mu);
+
+ pthread_mutex_lock(&p->mu);
+
+ if (p->abort) {
+ for (size_t i = 0; i < p->n; i++)
+ free(p->items[i].text);
+ p->n = 0;
+ xbuf o;
+ xbuf_init(&o);
+ xbuf_put(&o, pstyle_transition(open, PSTYLE_NORMAL));
+ xbuf_put(&o, "\n");
+ xbuf_put(&o, DIM);
+ xbuf_put(&o, "[stopped]");
+ xbuf_put(&o, RESET);
+ xbuf_put(&o, "\n");
+ out_str(o.data ? o.data : "");
+ xbuf_free(&o);
+ open = PSTYLE_NORMAL;
+ pthread_mutex_unlock(&p->mu);
+ break;
+ }
+
+ if (p->n > 0 && p->items[0].kind == PSEG_VERBATIM) {
+ // Preceding prose is already revealed; write the line immediately.
+ xbuf o;
+ xbuf_init(&o);
+ xbuf_put(&o, pstyle_transition(open, PSTYLE_NORMAL));
+ xbuf_put(&o, p->items[0].text);
+ out_str(o.data ? o.data : "");
+ xbuf_free(&o);
+ open = PSTYLE_NORMAL;
+ free(p->items[0].text);
+ pacer_shift(p);
+ pthread_mutex_unlock(&p->mu);
+ continue;
+ }
+
+ if (p->n > 0 && p->items[0].kind == PSEG_TEXT) {
+ size_t budget = pacer_reveal_count(p);
+ if (budget)
+ pacer_drain_text(p, &open, budget);
+ }
+
+ bool done = false;
+ if (p->finished && p->n == 0) {
+ out_str(pstyle_transition(open, PSTYLE_NORMAL));
+ out_str("\n");
+ open = PSTYLE_NORMAL;
+ done = true;
+ }
+ pthread_mutex_unlock(&p->mu);
+
+ if (done)
+ break;
+ }
+ return NULL;
+}
+
+static int emit_cb(const sess_emit *e, void *user) {
+ pacer *p = (pacer *) user;
+ if (!e || !p)
+ return 0;
+ switch (e->kind) {
+ case SESS_EMIT_TEXT:
+ pacer_emit_text(p, PSTYLE_NORMAL, e->text);
+ break;
+ case SESS_EMIT_REASONING:
+ pacer_emit_text(p, PSTYLE_DIM, e->text);
+ break;
+ case SESS_EMIT_VERBATIM:
+ pacer_emit_verbatim(p, e->text);
+ break;
+ case SESS_EMIT_RESULT:
+ if (e->from_shell) {
+ char *v = xstrfmt(DIM "%s" RESET, e->text);
+ pacer_emit_verbatim(p, v);
+ free(v);
+ } else {
+ pacer_emit_verbatim(p, e->text);
+ }
+ break;
+ case SESS_EMIT_STATUS:
+ break; /* no inline output */
+ }
+ return 0;
+}
+
+/* Run one turn with the pacer active: start it, feed emits through emit_cb,
+ * then drain (or, if the user stopped, clear + note it) and join. */
+static int run_turn_paced(session *s, const char *prompt,
+ std::atomic<bool> *stop, char **err, int *result) {
+ pacer *p = pacer_new();
+ pacer_start(p);
+ int rc = session_run_turn(s, prompt, stop, emit_cb, p, err, result);
+ if (result && *result == SESS_TURN_STOPPED)
+ pacer_abort(p);
+ else
+ pacer_finish(p);
+ pthread_join(p->th, NULL);
+ pacer_free(p);
+ return rc;
+}
+
+/* ------------------------------------------------------------------ */
+/* Destructive-command confirmation hook */
+/* ------------------------------------------------------------------ */
+
+static bool interactive_confirm(const char *command, void *ud) {
+ input_key_reader *reader = (input_key_reader *) ud;
+ fprintf(stderr, "\n[confirm] run destructive command `%s`? [y/N] ",
+ command ? command : "");
+ fflush(stderr);
+ if (!reader)
+ return true; /* non-interactive: allow */
+ for (int i = 0; i < 200; i++) {
+ input_key key;
+ if (!input_key_reader_recv(reader, &key, NULL, NULL)) {
+ sleep_ms(20);
+ continue;
+ }
+ if (key.kind == KEY_CHAR) {
+ fputc((char)key.cp, stderr);
+ fputc('\n', stderr);
+ if (key.cp == 'y' || key.cp == 'Y')
+ return true;
+ if (key.cp == 'n' || key.cp == 'N')
+ return false;
+ } else if (key.kind == KEY_ENTER) {
+ fputc('\n', stderr);
+ return false;
+ } else if (key.kind == KEY_ESC || key.kind == KEY_CTRL_C) {
+ fputc('\n', stderr);
+ return false;
+ }
+ }
+ return false; /* timeout: deny destructive commands by default */
+}
+
+/* ------------------------------------------------------------------ */
+/* /tools, /agents, /tasks, /memories listing */
+/* ------------------------------------------------------------------ */
+
+static void list_tools(void) {
+ const tool_entry *entries = NULL;
+ size_t n = tool_list_entries(&entries);
+ for (size_t i = 0; i < n; i++)
+ out_fmt(" %-12s %s\n", entries[i].name, entries[i].desc);
+}
+
+static void list_agents(const dotagents *dot, session *s) {
+ char *cur = session_profile_name(s);
+ out_fmt("current sub-agent: %s\n", cur ? cur : "default (main agent)");
+ free(cur);
+ for (size_t i = 0; i < dot->sub_agent_count; i++) {
+ const sub_agent *a = &dot->sub_agents[i];
+ if (!a->enabled)
+ continue;
+ char *cur2 = session_profile_name(s);
+ bool active = (cur2 && xstreq(cur2, a->id));
+ free(cur2);
+ out_fmt(" %c %s — %s\n", active ? '*' : ' ', a->id,
+ a->description ? a->description : "");
+ }
+ if (dot->sub_agent_count == 0)
+ out_str(" (none defined in .agents/agents/)\n");
+}
+
+static void list_tasks(const dotagents *dot) {
+ if (dot->task_count == 0) {
+ out_str("no repeat tasks defined (.agents/tasks/<id>/task.md)\n");
+ return;
+ }
+ for (size_t i = 0; i < dot->task_count; i++) {
+ const task_entry *t = &dot->tasks[i];
+ char *iv = t->has_interval ? itoa_dup((long long)t->interval_minutes)
+ : xstrdup("?");
+ out_fmt(" %s %6s min %s — %s%s\n", t->enabled ? "on " : "off", iv,
+ t->id, t->name ? t->name : "",
+ t->run_on_startup ? " (runs at startup)" : "");
+ free(iv);
+ }
+}
+
+static void list_memories(const dotagents *dot) {
+ if (dot->memory_count == 0) {
+ out_str("no memories defined (.agents/memories/*.md)\n");
+ return;
+ }
+ for (size_t i = 0; i < dot->memory_count; i++) {
+ const memory *m = &dot->memories[i];
+ xbuf tags;
+ xbuf_init(&tags);
+ for (size_t j = 0; j < m->tag_count; j++) {
+ if (j)
+ xbuf_put(&tags, ", ");
+ xbuf_put(&tags, m->tags[j]);
+ }
+ out_fmt(" %-8s %s — %s", m->importance ? m->importance : "", m->id,
+ m->title ? m->title : "");
+ if (m->tag_count && tags.data)
+ out_fmt(" [%s]", tags.data);
+ out_str("\n");
+ xbuf_free(&tags);
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Turn worker (so ESC / Ctrl+C can stop it) */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ session *s;
+ char *prompt;
+ std::atomic<bool> *stop;
+ char *err;
+ int result;
+ std::atomic<bool> done;
+} turn_ctx;
+
+static void *turn_thread(void *arg) {
+ turn_ctx *tc = (turn_ctx *) arg;
+ char *err = NULL;
+ int result = SESS_TURN_COMPLETED;
+ int rc = run_turn_paced(tc->s, tc->prompt, tc->stop, &err, &result);
+ if (err)
+ tc->err = err;
+ tc->result = result;
+ tc->done.store(true);
+ (void)rc;
+ return NULL;
+}
+
+static int run_turn_interactive(session *s, const char *prompt,
+ input_key_reader *reader) {
+ std::atomic<bool> stop(false);
+ turn_ctx tc = {s, xstrdup(prompt), &stop, NULL, SESS_TURN_COMPLETED, false};
+ pthread_t th;
+ if (pthread_create(&th, NULL, turn_thread, &tc) != 0) {
+ char *err = NULL;
+ int result = 0;
+ int rc = run_turn_paced(s, prompt, &stop, &err, &result);
+ if (rc < 0 && err) {
+ out_fmt(RED "error: %s" RESET "\n", err);
+ free(err);
+ }
+ free(tc.prompt);
+ return result;
+ }
+ while (!tc.done.load()) {
+ input_key key;
+ if (input_key_reader_recv(reader, &key, NULL, NULL)) {
+ if (key.kind == KEY_ESC || key.kind == KEY_CTRL_C)
+ stop.store(true); /* the pacer prints [stopped] when it drains */
+ } else {
+ sleep_ms(25);
+ }
+ }
+ pthread_join(th, NULL);
+ if (tc.err) {
+ out_fmt(RED "error: %s" RESET "\n", tc.err);
+ free(tc.err);
+ }
+ free(tc.prompt);
+ return tc.result;
+}
+
+/* ------------------------------------------------------------------ */
+/* /goal */
+/* ------------------------------------------------------------------ */
+
+static void report_goal(session *s) {
+ const goal_report *r = session_goal_report(s);
+ if (r) {
+ out_fmt("last goal (%s): \"%s\" — %lu of %lu attempt(s), ~%llu input "
+ "tokens\n",
+ r->met ? "met" : "not met", r->criteria, r->turns_done,
+ r->max_tries, (unsigned long long)r->tokens_used);
+ } else {
+ out_str("no /goal run yet this session\n");
+ }
+}
+
+static goal_report *make_report(const goal_spec *spec, unsigned long turns,
+ unsigned long long tokens, bool met) {
+ goal_report *rep = (goal_report *) xmalloc(sizeof *rep);
+ rep->criteria = xstrdup(spec->criteria);
+ rep->max_tries = spec->max_tries;
+ rep->turns_done = turns;
+ rep->tokens_used = tokens;
+ rep->met = met;
+ return rep;
+}
+
+static void run_goal(session *s, const goal_spec *spec,
+ input_key_reader *reader) {
+ char *feedback = NULL;
+ unsigned long long tokens_used = 0;
+ for (unsigned long attempt = 1; attempt <= spec->max_tries; attempt++) {
+ out_fmt("\n" DIM "── goal attempt %lu/%lu ──" RESET "\n", attempt,
+ spec->max_tries);
+ char *prompt = goal_prompt(spec, attempt, feedback);
+ unsigned long before = session_context_tokens(s);
+ int result = run_turn_interactive(s, prompt, reader);
+ free(prompt);
+ unsigned long after = session_context_tokens(s);
+ /* Accumulate the tokens added by this attempt (context delta), not
+ * the running context total. */
+ if (after > before)
+ tokens_used += after - before;
+ if (result == SESS_TURN_STOPPED) {
+ session_set_goal_report(s,
+ make_report(spec, attempt, tokens_used,
+ false));
+ out_fmt("goal stopped by the user on attempt %lu\n", attempt);
+ free(feedback);
+ return;
+ }
+ char *last = session_last_assistant_text(s);
+ goal_verdict v = {false, NULL};
+ char *err = NULL;
+ int rc = session_evaluate_goal(s, spec, last ? last : "", &v, &err);
+ free(last);
+ if (rc != 0) {
+ out_fmt(RED "error: %s" RESET "\n", err ? err : "evaluating goal");
+ free(err);
+ free(v.reason);
+ free(feedback);
+ return;
+ }
+ out_fmt(DIM "reviewer: %s — %s" RESET "\n", v.met ? "MET" : "NOT MET",
+ v.reason ? v.reason : "");
+ if (v.met) {
+ session_set_goal_report(s,
+ make_report(spec, attempt, tokens_used,
+ true));
+ out_fmt("goal met after %lu attempt(s)\n", attempt);
+ free(v.reason);
+ free(feedback);
+ return;
+ }
+ free(feedback);
+ feedback = v.reason;
+ v.reason = NULL;
+ }
+ session_set_goal_report(s,
+ make_report(spec, spec->max_tries, tokens_used,
+ false));
+ out_fmt("goal not met after %lu attempt(s); try again with a higher cap\n",
+ spec->max_tries);
+ free(feedback);
+}
+
+/* ------------------------------------------------------------------ */
+/* Commands (returns true when the session should end) */
+/* ------------------------------------------------------------------ */
+
+/* Banner (defined below); reprinted inline on a model/agent switch. The
+ * provider-reported context window is adopted before the banner is printed,
+ * so it is shown correctly the first time. */
+static void banner_print(session *s, const dotagents *dot);
+static void banner_worker_join(void);
+static int banner_refresh_start(session *s);
+
+static bool handle_command(session *s, const dotagents *dot, const char *cmd,
+ input_key_reader *reader) {
+ const char *p = cmd;
+ while (*p == ' ' || *p == '\t')
+ p++;
+ const char *head_start = p;
+ while (*p && *p != ' ' && *p != '\t')
+ p++;
+ size_t head_len = (size_t)(p - head_start);
+ char *head = xstrndup(head_start, head_len);
+ const char *arg = p;
+ while (*arg == ' ' || *arg == '\t')
+ arg++;
+
+ if (xstreq(head, "exit") || xstreq(head, "quit")) {
+ free(head);
+ return true;
+ }
+ if (xstreq(head, "clear")) {
+ session_clear_history(s);
+ out_str(ESC_CURSOR_POSITION(1, 1) ESC_CLEAR_SCREEN "\x1b[3J");
+ free(head);
+ return false;
+ }
+ if (xstreq(head, "undo")) {
+ int removed = session_undo_last_turn(s);
+ if (removed < 0)
+ out_str("nothing to undo (no user turn in context)\n");
+ else
+ out_fmt("rolled back the last turn (%d message(s) removed from "
+ "context)\n",
+ removed);
+ free(head);
+ return false;
+ }
+ if (xstreq(head, "compact")) {
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *err = NULL;
+ int rc = session_compact_if_needed(s, true, &report, &err);
+ if (rc < 0) {
+ out_fmt(RED "error: %s" RESET "\n", err ? err : "compacting");
+ free(err);
+ } else if (rc == 1) {
+ session_print_compact(&report);
+ if (session_context_limit(s) == 0)
+ out_str("note: no context window is known, so automatic "
+ "compaction stays off (set `context_limit` to enable "
+ "it)\n");
+ } else {
+ if (session_context_limit(s) == 0)
+ out_str("nothing to compact yet; automatic compaction is "
+ "also off because no context window is known (set "
+ "`context_limit` to enable it)\n");
+ else
+ out_str("nothing to compact yet\n");
+ }
+ free(head);
+ return false;
+ }
+ if (xstreq(head, "save")) {
+ const char *path = arg[0] ? arg : "/tmp/llsh-session.json";
+ char *err = NULL;
+ size_t count = session_message_count(s);
+ if (session_save(s, path, &err) != 0) {
+ out_fmt(RED "error: %s" RESET "\n", err ? err : "saving");
+ free(err);
+ } else {
+ out_fmt("saved %zu message(s) to %s — resume with: llsh "
+ "--resume %s\n",
+ count, path, path);
+ }
+ free(head);
+ return false;
+ }
+ if (xstreq(head, "export")) {
+ const char *path = arg[0] ? arg : "llsh-transcript.md";
+ char *md = session_transcript_markdown(s);
+ char *err = NULL;
+ if (write_file_from_buf(path, md, strlen(md), &err) != 0) {
+ out_fmt(RED "error: %s" RESET "\n", err ? err : "exporting");
+ free(err);
+ } else {
+ out_fmt("exported %zu message(s) to %s\n",
+ session_message_count(s), path);
+ }
+ free(md);
+ free(head);
+ return false;
+ }
+ if (xstreq(head, "goal")) {
+ if (arg[0] == '\0') {
+ report_goal(s);
+ } else {
+ goal_spec spec;
+ char *err = NULL;
+ if (goal_parse(arg, &spec, &err) != 1) {
+ out_fmt(RED "error: %s" RESET "\n", err ? err : "parsing goal");
+ free(err);
+ free(head);
+ return false;
+ }
+ out_fmt("goal: %s — up to %lu try(ies)\n", spec.criteria,
+ spec.max_tries);
+ run_goal(s, &spec, reader);
+ goal_spec_free(&spec);
+ }
+ free(head);
+ return false;
+ }
+ if (xstreq(head, "model")) {
+ if (arg[0] == '\0') {
+ char *m = session_model(s);
+ out_fmt("current model: %s\n", m);
+ free(m);
+ } else {
+ char *err = NULL;
+ if (session_set_model(s, arg, &err) != 0) {
+ out_fmt(RED "error: %s" RESET "\n",
+ err ? err : "switching model");
+ free(err);
+ } else {
+ banner_refresh_start(s);
+ out_fmt("model set to %s\n", arg);
+ /* Reprint the banner: the refresh above has landed, so the
+ * `model:` and `context window:` lines both reflect the new
+ * selection. */
+ banner_print(s, dot);
+ }
+ }
+ free(head);
+ return false;
+ }
+ if (xstreq(head, "tools")) {
+ list_tools();
+ free(head);
+ return false;
+ }
+ if (xstreq(head, "agent")) {
+ if (arg[0] == '\0') {
+ list_agents(dot, s);
+ } else if (xstreq(arg, "default")) {
+ char *err = NULL;
+ session_set_profile(s, NULL, &err);
+ if (err) {
+ out_fmt(RED "error: %s" RESET "\n", err);
+ free(err);
+ } else {
+ out_str("sub-agent reset to default (main agent)\n");
+ banner_print(s, dot);
+ }
+ } else {
+ char *err = NULL;
+ if (session_set_profile(s, arg, &err) != 0) {
+ out_fmt(RED "error: %s" RESET "\n",
+ err ? err : "switching agent");
+ free(err);
+ } else {
+ out_fmt("sub-agent set to %s\n", arg);
+ /* Reprint the banner so the `sub-agent:` line reflects the
+ * new selection immediately. */
+ banner_print(s, dot);
+ }
+ }
+ free(head);
+ return false;
+ }
+ if (xstreq(head, "agents")) {
+ if (dot->found_count == 0) {
+ out_str("no .agents/ found (looked for .agents and ~/.agents)\n");
+ } else {
+ for (size_t i = 0; i < dot->found_count; i++)
+ out_fmt(" %s: %s\n", layer_display(dot->found_layers[i]),
+ dot->found_paths[i]);
+ out_fmt(" system-prompt.md: %s\n"
+ " agents.md: %s\n"
+ " models.json: %zu preset(s)%s\n"
+ " mcp.json: %zu server(s)\n"
+ " skills: %zu\n"
+ " sub-agents: %zu\n"
+ " tasks: %zu\n"
+ " memories: %zu\n",
+ dot->system_prompt ? "yes" : "no",
+ dot->agents_md ? "yes" : "no",
+ dot->has_models ? dot->models.preset_count : 0,
+ (dot->has_models && dot->models.default_name)
+ ? xstrfmt(" (default: %s)",
+ dot->models.default_name)
+ : "",
+ dot->mcp_count, dot->skill_count, dot->sub_agent_count,
+ dot->task_count, dot->memory_count);
+ }
+ for (size_t i = 0; i < dot->warnings.len; i++)
+ out_fmt(" warning: %s\n", dot->warnings.items[i]);
+ free(head);
+ return false;
+ }
+ if (xstreq(head, "tasks")) {
+ list_tasks(dot);
+ free(head);
+ return false;
+ }
+ if (xstreq(head, "memories")) {
+ list_memories(dot);
+ free(head);
+ return false;
+ }
+ if (xstreq(head, "help")) {
+ out_str(" /model [name] show/switch model, or a `models` config\n"
+ " profile name (e.g. `/model local`)\n"
+ " /agent [name] list sub-agents, switch, or 'default'\n"
+ " /agents show the loaded .agents configuration\n"
+ " /tasks list repeat tasks\n"
+ " /memories list memories\n"
+ " /compact summarize the earlier context now\n"
+ " /undo roll back the last user turn\n"
+ " /clear clear the screen and the conversation\n"
+ " /save [path] save the session (default: "
+ "/tmp/llsh-session.json; resume with --resume <path>)\n"
+ " /export [path] write the transcript to Markdown\n"
+ " /goal [criteria] run a goal-based loop\n"
+ " /tools list available tools\n"
+ " /exit quit\n"
+ "\nkeys:\n"
+ " Enter submit\n"
+ " Esc / Ctrl+C stop the running generation\n"
+ " Ctrl+D quit when the line is empty\n"
+ " Up / Down walk the multi-line buffer; at the top or\n"
+ " bottom, recall the previous / next input\n"
+ " Left/Right, Home, End, Backspace, Delete edit the line\n"
+ " Ctrl+Y yank the most recently deleted text\n"
+ " Ctrl+_ undo the last edit\n"
+ " \\+Enter newline (works in every terminal)\n");
+ free(head);
+ return false;
+ }
+ out_fmt(RED "unknown command `/%.*s` (try /help)" RESET "\n",
+ (int)head_len, head);
+ free(head);
+ return false;
+}
+
+/* ------------------------------------------------------------------ */
+/* REPL */
+/* ------------------------------------------------------------------ */
+
+/* True when a line submitted in the interactive shell is the shell's own
+ * `exit` builtin (with or without an exit-status argument). Mirrors
+ * shell_line_is_exit in main.rs. */
+static bool shell_line_is_exit(const char *line) {
+ while (*line == ' ' || *line == '\t')
+ line++;
+ const char *p = line;
+ while (*p && *p != ' ' && *p != '\t')
+ p++;
+ size_t n = (size_t)(p - line);
+ return n == 4 && xstreq(line, "exit");
+}
+
+/* Commit a shell-mode visit to the conversation context so the model sees the
+ * commands the user ran and their output. The captured PTY text already
+ * contains each echoed command line (with the prompt) followed by its output,
+ * sanitized of terminal escapes, so it is recorded as-is (trimmed and capped
+ * at `max_chars`). One user message + a short assistant acknowledgement.
+ * Must be called before `shell_free`; empty visits record nothing. */
+static void shell_transcript_record(session *s, interactive_shell *sh,
+ unsigned long max_chars) {
+ if (!s || !sh)
+ return;
+ char *out = shell_captured(sh);
+ size_t len = strlen(out);
+ /* Trim trailing whitespace so the message does not end in blank lines. */
+ while (len > 0 && (out[len - 1] == '\n' || out[len - 1] == '\r' ||
+ out[len - 1] == ' ' || out[len - 1] == '\t'))
+ len--;
+ if (len == 0) {
+ free(out);
+ return; /* empty visit: nothing to record */
+ }
+ bool truncated = (max_chars > 0 && len > max_chars);
+ if (truncated)
+ len = max_chars;
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_putn(&b, out, len);
+ xbuf_push(&b, '\n');
+ if (truncated)
+ xbuf_put(&b, "… (shell output truncated)\n");
+ char *body = xbuf_steal(&b);
+ xbuf_free(&b);
+ free(out);
+ session_push_history(s, msg_user_text(body));
+ session_push_history(s, msg_assistant_text(
+ "Shell visit noted; the commands and their output are in context. "
+ "Continue in chat or press Backtab to return to the shell."));
+ session_set_last_context_tokens(s, 0); /* re-reported next turn */
+ free(body);
+}
+
+/* Make the process — and with it the file tools, `run_shell`, and the
+ * workspace `.agents` layer — follow the directory the shell ended in.
+ * Mirrors adopt_shell_cwd in main.rs. */
+static void adopt_shell_cwd(session *s, interactive_shell *sh) {
+ char *dir = shell_final_cwd(sh);
+ if (!dir)
+ return;
+ char *cur = NULL;
+ if (getcwd(cur, 0) == NULL) {
+ cur = xstrdup(".");
+ }
+ bool same = false;
+ if (cur)
+ same = xstreq(dir, cur);
+ free(cur);
+ if (same) {
+ free(dir);
+ return;
+ }
+ if (chdir(dir) != 0) {
+ out_fmt(DIM "warning: the shell ended in `%s`, but llsh could not "
+ "change to it" RESET "\n",
+ dir);
+ free(dir);
+ return;
+ }
+ free(dir);
+ char *err = NULL;
+ if (session_refresh_dot(s, NULL, NULL, &err) != 0) {
+ fprintf(stderr, "warning: could not refresh the agent layer after "
+ "changing directory: %s\n",
+ err ? err : "unknown error");
+ free(err);
+ }
+}
+
+/* Terminal resized: the shell-mode branch propagates the new size to the
+ * PTY; in chat mode llsh owns no screen state, so the terminal reflows the
+ * scrollback itself (see the g_winch branch in repl). */
+static volatile sig_atomic_t g_winch = 0;
+static void on_winch(int sig) {
+ (void)sig;
+ g_winch = 1;
+}
+
+/* Echo a codepoint as UTF-8, the way the terminal would show it. */
+static void echo_cp(unsigned int cp) {
+ char tmp[8];
+ size_t i = 0;
+ if (cp < 0x80)
+ tmp[i++] = (char)cp;
+ else if (cp < 0x800) {
+ tmp[i++] = (char)(0xC0 | (cp >> 6));
+ tmp[i++] = (char)(0x80 | (cp & 0x3F));
+ } else if (cp < 0x10000) {
+ tmp[i++] = (char)(0xE0 | (cp >> 12));
+ tmp[i++] = (char)(0x80 | ((cp >> 6) & 0x3F));
+ tmp[i++] = (char)(0x80 | (cp & 0x3F));
+ } else {
+ tmp[i++] = (char)(0xF0 | (cp >> 18));
+ tmp[i++] = (char)(0x80 | ((cp >> 12) & 0x3F));
+ tmp[i++] = (char)(0x80 | ((cp >> 6) & 0x3F));
+ tmp[i++] = (char)(0x80 | (cp & 0x3F));
+ }
+ tmp[i] = '\0';
+ out_str(tmp);
+}
+
+/* Current terminal width in columns (from the controlling tty). The editor
+ * must lay the buffer out against this width, or a line that wraps will leave
+ * stale rows behind and the caret will land on the wrong row. */
+static size_t term_cols(void) {
+ struct winsize ws;
+ if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0)
+ return (size_t)ws.ws_col;
+ return 80;
+}
+
+/* Map the editor caret (a byte offset) to a physical (row, col) on a terminal
+ * of width `w`. Shared with the tests via input.cpp. */
+static void caret_phys(const char *text, size_t caret, size_t w,
+ size_t *out_row, size_t *out_col) {
+ input_wrap_caret(text, caret, w, out_row, out_col);
+}
+
+/* Total physical rows of the buffer and the caret's physical (row, col). */
+static void editor_geom(const editor *ed, size_t w, size_t *total,
+ size_t *cr, size_t *cc) {
+ const char *t = ed->text ? ed->text : "";
+ *total = input_wrap_total(t, w);
+ caret_phys(t, ed->caret, w, cr, cc);
+}
+
+/* Display width of the character ending just before `caret` — the one a
+ * backspace at the caret would delete. Zero-width characters count as 1. */
+static size_t prev_char_width(const char *s, size_t caret) {
+ if (!s || caret == 0)
+ return 1;
+ size_t start = caret;
+ while (start > 0 && (((unsigned char)s[start - 1]) & 0xC0) == 0x80)
+ start--;
+ size_t w = input_display_len_n(s + start, caret - start);
+ return w ? w : 1;
+}
+
+/* Move the visible cursor from where the previous draw or edit parked it
+ * (from_row, from_col) to the editor caret, without touching the printed
+ * text. Vertical first, then horizontal: the horizontal step lands the
+ * column regardless of how the terminal resolves the vertical one (e.g. out
+ * of a deferred-wrap cell). */
+static void caret_move_to(const editor *ed, size_t w, size_t from_row,
+ size_t from_col) {
+ size_t nr, nc;
+ caret_phys(ed->text ? ed->text : "", ed->caret, w, &nr, &nc);
+ if (nr < from_row)
+ esc_cursor_up((u32)(from_row - nr));
+ else if (nr > from_row)
+ esc_cursor_down((u32)(nr - from_row));
+ if (nc < from_col)
+ esc_cursor_left((u32)(from_col - nc));
+ else if (nc > from_col)
+ esc_cursor_right((u32)(nc - from_col));
+ /* esc_cursor_* write via fprintf without flushing; a line-buffered tty
+ * would hold the movement until the next out_str. Flush now so an arrow
+ * key responds immediately instead of on the next keypress. */
+ fflush(stdout);
+}
+
+/* Write the input block and park the visible caret at the editor caret.
+ * After printing the whole buffer the terminal cursor sits where the
+ * delayed-wrap model puts the final byte — the "end" position. Walk from there
+ * to the caret: up/down to its row (CUU/CUD preserve the column, which stays
+ * the end column), then left/right to the caret's column. */
+static void input_block_draw(const editor *ed) {
+ out_str(CHAT_PROMPT);
+ const char *text = ed->text;
+ size_t len = text ? strlen(text) : 0;
+ if (len > 0)
+ out_str(text);
+ size_t w = term_cols();
+ const char *t = text ? text : "";
+ size_t er, ec;
+ caret_phys(t, len, w, &er, &ec);
+ size_t cr, cc;
+ caret_phys(t, ed->caret, w, &cr, &cc);
+ if (ec == w - 1) {
+ /* The last byte landed in the row's final cell. Some terminals leave
+ * the cursor in a pending-wrap state (visually on the next row); a CR
+ * collapses it back to this row, and the right walk restores the
+ * column so the park below starts from a concrete cell. */
+ out_str("\r");
+ esc_cursor_right((u32)ec);
+ }
+ if (cr < er) {
+ for (size_t i = 0; i < er - cr; i++)
+ out_str(ESC_CURSOR_UP(1));
+ } else if (cr > er) {
+ for (size_t i = 0; i < cr - er; i++)
+ out_str(ESC_CURSOR_DOWN(1));
+ }
+ if (cc < ec) {
+ for (size_t i = 0; i < ec - cc; i++)
+ out_str(ESC_CURSOR_LEFT(1));
+ } else if (cc > ec) {
+ for (size_t i = 0; i < cc - ec; i++)
+ out_str(ESC_CURSOR_RIGHT(1));
+ }
+ fflush(stdout);
+}
+
+/* Repaint the whole input block, parking the caret. The visible cursor sits
+ * on `old_caret_row` of the previously drawn block (`old_total` physical
+ * rows). The erase walk stays inside the old block's footprint, so it can
+ * never scroll the screen (cursor-down clamps at the last row); the repaint
+ * then scrolls naturally if the new block grows past the bottom row. */
+static void line_redraw(const editor *ed, size_t old_caret_row,
+ size_t old_caret_col, size_t old_total) {
+ if (old_total == 0) old_total = 1;
+ if (old_caret_row >= old_total) old_caret_row = old_total - 1;
+ (void)old_caret_col;
+ out_str(ESC_CURSOR_LEFT(1) ESC_CURSOR_COLUMN_HOME ESC_CLEAR_LINE);
+ for (size_t i = 0; i < old_caret_row; i++)
+ out_str(ESC_CURSOR_PREVIOUS(1) ESC_CLEAR_LINE);
+ for (size_t i = 0; i + 1 < old_total; i++)
+ out_str(ESC_CURSOR_DOWN(1) ESC_CLEAR_LINE);
+ for (size_t i = 0; i + 1 < old_total; i++)
+ out_str(ESC_CURSOR_PREVIOUS(1));
+ input_block_draw(ed);
+}
+
+/* The session's static header, printed verbatim above the prompt. Drawn
+ * once at startup (after the provider-reported context window has been
+ * adopted), and reprinted inline on a model/agent switch. */
+static void banner_print(session *s, const dotagents *dot) {
+ char *model = session_model(s);
+ out_fmt("llsh — model: %s\n", model);
+ free(model);
+ unsigned long limit = session_context_limit(s);
+ if (limit > 0) {
+ char *ls = itoa_dup((long long)limit);
+ out_fmt("context window: %s\n", ls);
+ free(ls);
+ } else {
+ out_str("context window: unknown — automatic compaction is off; set "
+ "`context_limit` in `.agents/models.json` or pass "
+ "--context-limit to enable it\n");
+ }
+ char *preset = session_active_preset(s);
+ if (preset) {
+ out_fmt("model preset: %s (from .agents models.json)\n", preset);
+ free(preset);
+ }
+ char *profile = session_profile_name(s);
+ if (profile) {
+ out_fmt("sub-agent: %s\n", profile);
+ free(profile);
+ }
+ for (size_t i = 0; i < dot->found_count; i++)
+ out_fmt("loaded %s: %s\n", layer_display(dot->found_layers[i]),
+ dot->found_paths[i]);
+ for (size_t i = 0; i < dot->warnings.len; i++)
+ fprintf(stderr, "warning: %s\n", dot->warnings.items[i]);
+ out_str("type /help for commands, /exit to quit\n");
+}
+
+/* Background context-window refresh (started on a /model switch). The
+ * fetch runs in a worker thread so the prompt stays responsive; the banner
+ * is reprinted at the call site once the worker has landed. */
+static pthread_mutex_t banner_mu = PTHREAD_MUTEX_INITIALIZER;
+static pthread_t banner_worker;
+
+static void *banner_worker_run(void *arg) {
+ session *s = (session *)arg;
+ session_refresh_context_limit(s);
+ return NULL;
+}
+
+/* Start the refresh worker (joining any previous one first). Returns 0 on
+ * success; on failure the limit is adopted synchronously instead. */
+static int banner_refresh_start(session *s) {
+ pthread_mutex_lock(&banner_mu);
+ pthread_t old = banner_worker;
+ if (old != 0) {
+ pthread_mutex_unlock(&banner_mu);
+ pthread_join(old, NULL);
+ pthread_mutex_lock(&banner_mu);
+ }
+ int rc = pthread_create(&banner_worker, NULL, banner_worker_run, s);
+ if (rc != 0) {
+ banner_worker = 0;
+ pthread_mutex_unlock(&banner_mu);
+ session_refresh_context_limit(s);
+ return -1;
+ }
+ pthread_mutex_unlock(&banner_mu);
+ return 0;
+}
+
+/* Join the background refresh worker, if one is running (idempotent).
+ * Must be called before the session is freed, since the worker reads it. */
+static void banner_worker_join(void) {
+ pthread_mutex_lock(&banner_mu);
+ pthread_t w = banner_worker;
+ banner_worker = 0;
+ pthread_mutex_unlock(&banner_mu);
+ if (w != 0)
+ pthread_join(w, NULL);
+}
+
+/* Clear the pending flag (used after an immediate reprint). */
+static int repl(session *s, const dotagents *dot) {
+ banner_print(s, dot);
+
+ /* The reader takes ownership of the ctx (and its parser) and releases
+ * them in input_key_reader_free, so heap-allocate here. */
+ key_parser *parser = (key_parser *) xmalloc(sizeof *parser);
+ key_parser_init(parser);
+ reader_ctx *ctx = (reader_ctx *) xmalloc(sizeof *ctx);
+ memset(ctx, 0, sizeof *ctx);
+ ctx->parser = parser;
+ input_key_reader *reader = input_key_reader_new(ctx);
+
+ if (input_setup_terminal() != 0) {
+ input_key_reader_free(reader);
+ fprintf(stderr, "could not enter raw mode\n");
+ return -1;
+ }
+ if (input_key_reader_start(reader) != 0) {
+ input_teardown_terminal();
+ input_key_reader_free(reader);
+ fprintf(stderr, "could not start the input reader\n");
+ return -1;
+ }
+ set_confirm_hook(interactive_confirm, reader);
+ struct sigaction sa;
+ memset(&sa, 0, sizeof sa);
+ sa.sa_handler = on_winch;
+ (void)sigaction(SIGWINCH, &sa, NULL);
+
+ editor ed;
+ editor_init(&ed);
+ char *hist_path = input_default_history_path();
+ input_history *hist = input_history_open(hist_path);
+ if (!hist)
+ hist = input_history_new();
+ free(hist_path);
+
+ bool running = true;
+ /* True when we're exiting with the cursor already parked at the start of a
+ * fresh line (a newline was the last thing emitted). The teardown must not
+ * add another, or the shell prompt lands a line too low. */
+ bool clean_exit = false;
+ /* Interactive shell state (Backtab to enter/leave). NULL = chat mode. */
+ interactive_shell *shell = NULL;
+ editor shell_shadow;
+ editor_init(&shell_shadow);
+ bool shell_exit_requested = false;
+
+ /* Leave shell mode: adopt where the shell ended, tear it down, and return
+ * to the chat prompt (or quit if the shell's `exit` was submitted). */
+ #define LEAVE_SHELL(record_visit) \
+ do { \
+ /* Record before freeing: the captured output lives in the \
+ * shell, and its reader must be joined first. */ \
+ if (record_visit) \
+ shell_transcript_record(s, shell, \
+ session_config(s)->max_output_chars); \
+ adopt_shell_cwd(s, shell); \
+ shell_free(shell); \
+ shell = NULL; \
+ editor_clear(&shell_shadow); \
+ bool quit = shell_exit_requested; \
+ shell_exit_requested = false; \
+ /* The shell is gone: llsh owns the keys again. */ \
+ out_str("\x1b[>1u"); \
+ /* Erase the shell's prompt (drawn on our line): the next thing \
+ * we print (or nothing, on quit) fully replaces it. */ \
+ out_str("\r" ESC_CLEAR_LINE); \
+ if (quit) { \
+ running = false; \
+ clean_exit = true; /* line is blank, cursor at col 0 */ \
+ } \
+ } while (0)
+
+ while (running) {
+ if (shell) {
+ if (g_winch) {
+ g_winch = 0;
+ shell_update_winsize(shell);
+ }
+ input_key key;
+ const unsigned char *raw;
+ size_t raw_len;
+ if (input_key_reader_recv(reader, &key, &raw, &raw_len)) {
+ if (key.kind == KEY_BACKTAB) {
+ input_raw_free(raw);
+ LEAVE_SHELL(true);
+ continue;
+ }
+ shell_write(shell, raw, raw_len);
+ input_raw_free(raw);
+ char *submitted = shell_shadow_apply(&shell_shadow, key);
+ if (submitted) {
+ input_history_push(hist, submitted);
+ if (shell_line_is_exit(submitted))
+ shell_exit_requested = true;
+ free(submitted);
+ }
+ continue;
+ }
+ if (shell_reap_if_dead(shell))
+ LEAVE_SHELL(true);
+ sleep_ms(50);
+ continue;
+ }
+
+ if (g_winch) {
+ g_winch = 0;
+ /* Chat mode: llsh owns no screen state — the terminal reflows the
+ * scrollback itself on resize, so there is nothing to do. */
+ }
+
+ /* Draw the current buffer and park the caret: a fresh prompt when
+ * empty, or a leftover buffer when returning from shell mode. The
+ * line is erased first — on a mode switch it still holds the
+ * shell's prompt, and at startup the parent shell's — so our prompt
+ * replaces it instead of printing behind it. */
+ out_str(ESC_CURSOR_LEFT(1) ESC_CURSOR_COLUMN_HOME ESC_CLEAR_LINE);
+ if (editor_is_empty(&ed))
+ out_str(CHAT_PROMPT);
+ else
+ input_block_draw(&ed);
+
+ /* Read keys until the buffer commits (submit, quit, or mode switch).
+ * Appends echo at the end of the buffer, backspaces erase in place,
+ * caret moves only move the cursor; mid-buffer edits repaint the
+ * whole block. */
+ for (;;) {
+ input_key key;
+ bool got_key = input_key_reader_recv(reader, &key, NULL, NULL);
+ /* A resize reflows the screen and may park the cursor off the text;
+ * re-anchor the block to the caret before acting, or the next key
+ * lands on a stale line. */
+ if (g_winch) {
+ g_winch = 0;
+ if (!editor_is_empty(&ed)) {
+ size_t w = term_cols();
+ size_t gt, gcr, gcc;
+ editor_geom(&ed, w, &gt, &gcr, &gcc);
+ line_redraw(&ed, gcr, gcc, gt);
+ }
+ }
+ if (!got_key) {
+ sleep_ms(50);
+ continue;
+ }
+ /* The visible cursor sits where the last draw parked the caret.
+ * The old block's physical footprint (row count + caret row/col)
+ * is what the erase walk covers, so it must be captured before
+ * the key mutates the buffer. */
+ size_t term_w = term_cols();
+ size_t old_total, old_caret_row, old_caret_col;
+ editor_geom(&ed, term_w, &old_total, &old_caret_row, &old_caret_col);
+
+ /* Up/Down: a single policy decides whether this press moves the
+ * caret or walks history. While composing, Up engages from the
+ * first line and Down only after a recall; once a recall is
+ * active the walk continues until any non-arrow key ends it. */
+ if (key.kind == KEY_UP || key.kind == KEY_DOWN) {
+ char *recalled = input_history_walk(
+ hist, key.kind == KEY_UP ? -1 : +1,
+ editor_caret_line(&ed), ed.text);
+ if (recalled) {
+ editor_set(&ed, recalled);
+ free(recalled);
+ /* The recalled entry may be multi-line: the block
+ * grows/shrinks, so repaint the whole thing (the old
+ * block's footprint is what line_redraw erases). */
+ line_redraw(&ed, old_caret_row, old_caret_col, old_total);
+ continue;
+ }
+ } else {
+ input_history_reset(hist);
+ }
+
+ size_t before = ed.text ? strlen(ed.text) : 0;
+ bool at_end = (ed.caret == before);
+ bool old_pending = false;
+ if (key.kind == KEY_BACKSPACE && at_end)
+ old_pending =
+ input_wrap_pending(ed.text ? ed.text : "", ed.caret, term_w);
+ input_action a;
+ editor_apply(&ed, key, &a);
+ bool changed = (ed.text ? strlen(ed.text) : 0) != before;
+ size_t new_caret_row = 0;
+ if (changed) {
+ size_t ncr, ncc;
+ caret_phys(ed.text ? ed.text : "", ed.caret, term_w, &ncr, &ncc);
+ new_caret_row = ncr;
+ }
+ bool moved = (key.kind == KEY_LEFT || key.kind == KEY_RIGHT ||
+ key.kind == KEY_HOME || key.kind == KEY_END ||
+ key.kind == KEY_UP || key.kind == KEY_DOWN);
+ bool newline = (a.kind == ACT_NEWLINE);
+
+ /* Render the key's effect shell-style: the text already on the
+ * terminal stays put; only what the key changed is emitted.
+ * - backspace at the end: "\b \b" erases the character in place
+ * (a wide one needs one more "\b " for its second cell); the
+ * blank left behind is past the caret, so no gap is visible.
+ * Use it only when the caret stays on the same physical row:
+ * a pending-wrap caret sits on the cell being erased, and an
+ * unwrap (deleting the last char of a row) would leave the
+ * visible cursor on the now-empty row below.
+ * - backspace deleting a newline at the end: the line below can
+ * only hold echo residue (the newline was the last byte), so
+ * the join is clear-that-line, up, across to the end of the
+ * line above.
+ * - append at the end: print the character — the terminal does
+ * the wrapping.
+ * - backslash+Enter at the end: the backslash became a newline —
+ * erase it and drop to the next line.
+ * - caret move: nothing was printed or erased, so just move the
+ * visible cursor to the caret.
+ * - everything else (mid-buffer insert, kills, yank, undo)
+ * repaints the whole block. */
+ if (changed && key.kind == KEY_BACKSPACE && at_end &&
+ old_caret_col != 0 && !old_pending &&
+ new_caret_row == old_caret_row) {
+ out_str(prev_char_width(ed.text, ed.caret) >= 2 ? "\b \b "
+ : "\b \b");
+ } else if (changed && key.kind == KEY_BACKSPACE && at_end &&
+ old_caret_col == 0 && !old_pending) {
+ out_str(ESC_CLEAR_LINE);
+ esc_cursor_up(1);
+ size_t cr, cc;
+ caret_phys(ed.text ? ed.text : "", ed.caret, term_w, &cr,
+ &cc);
+ if (cc > 0)
+ esc_cursor_right((u32)cc);
+ } else if (changed && at_end) {
+ if (key.kind == KEY_CHAR)
+ echo_cp(key.cp);
+ else if (key.kind == KEY_TAB)
+ out_str("\t");
+ else if (key.kind == KEY_NEWLINE)
+ out_str("\n");
+ else if (key.kind == KEY_CTRL_Y)
+ out_str(ed.killed ? ed.killed : "");
+ else
+ line_redraw(&ed, old_caret_row, old_caret_col, old_total);
+ } else if (newline && at_end) {
+ /* If the backslash wrapped onto its own visual row, erasing it
+ * leaves that row empty and a plain LF would drop past it,
+ * leaving a blank line. Collapse back to the end of the row
+ * above first, then drop down. */
+ size_t pr = 0, pc = 0;
+ bool wrap_split = false;
+ if (old_caret_row > 0 && old_caret_col == 1 && ed.caret >= 2 &&
+ ed.text[ed.caret - 2] != '\n') {
+ caret_phys(ed.text, ed.caret - 2, term_w, &pr, &pc);
+ if (pc == term_w - 1)
+ wrap_split = true;
+ }
+ if (wrap_split) {
+ out_str("\b ");
+ out_str(ESC_CURSOR_PREVIOUS(1));
+ if (pc > 0)
+ esc_cursor_right((u32)pc);
+ out_str("\n");
+ } else {
+ out_str("\b \n");
+ }
+ } else if (moved) {
+ caret_move_to(&ed, term_w, old_caret_row, old_caret_col);
+ } else if (changed || newline) {
+ line_redraw(&ed, old_caret_row, old_caret_col, old_total);
+ }
+
+ bool leave = false;
+ switch (a.kind) {
+ case ACT_NONE:
+ case ACT_STOP:
+ case ACT_NEWLINE: /* already repainted above; stay in the loop */
+ input_action_free(&a);
+ break;
+ case ACT_CLEAR_SCREEN:
+ input_action_free(&a);
+ out_str(ESC_CLEAR_SCREEN ESC_CURSOR_POSITION(1, 1));
+ input_block_draw(&ed);
+ break;
+ case ACT_QUIT:
+ input_action_free(&a);
+ running = false;
+ leave = true;
+ break;
+ case ACT_TOGGLE_MODE: {
+ input_action_free(&a);
+ /* Erase the whole chat prompt line so the shell's own prompt
+ * prints on a clean line instead of after the leftover bullet.
+ * (The Rust reference does the same: erase the frame and let
+ * the shell draw its own prompt — no bullet reprint here.) */
+ out_str(ESC_CURSOR_LEFT(1) ESC_CURSOR_COLUMN_HOME ESC_CLEAR_LINE);
+ interactive_shell *sh = shell_spawn();
+ if (!sh) {
+ out_fmt(RED "error: failed to start interactive shell" RESET
+ "\n");
+ leave = true;
+ break;
+ }
+ /* The shell owns the keys from here: give it ordinary xterm
+ * sequences, not kitty protocol. */
+ out_str("\x1b[>0u");
+ editor_clear(&shell_shadow);
+ /* Bound the in-memory capture at the tool output limit so a
+ * pathological command can't grow it without limit; the
+ * recorded transcript is trimmed to the same limit. */
+ sh->capture_cap = session_config(s)->max_output_chars;
+ shell_exit_requested = false;
+ shell = sh;
+ shell_update_winsize(shell);
+ leave = true;
+ break;
+ }
+ case ACT_SUBMIT: {
+ input_action_free(&a);
+ char *text = editor_text(&ed);
+ char *t = text;
+ size_t tn = strlen(t);
+ while (tn && (t[tn - 1] == ' ' || t[tn - 1] == '\n' ||
+ t[tn - 1] == '\t' || t[tn - 1] == '\r'))
+ t[--tn] = '\0';
+ while (*t == ' ' || *t == '\n' || *t == '\t' || *t == '\r')
+ t++;
+ if (tn != 0) {
+ input_history_push(hist, t);
+ /* Submit's newline must fire from the very end of the
+ * input: when the caret sits mid-line, on a wrapped row,
+ * or on a middle line, the visible cursor is parked there
+ * and the newline would land mid-block — the remaining
+ * text (and then the response) shifts up over it. Walk the
+ * cursor to the block's bottom-right first: down to the
+ * last physical row (CUD preserves the column), then to
+ * that row's end column. Enter itself never moves the
+ * editor caret, so `ed.caret` still describes where the
+ * visible cursor sits. */
+ {
+ size_t w = term_cols();
+ size_t len = strlen(ed.text);
+ size_t er, ec;
+ caret_phys(ed.text, len, w, &er, &ec);
+ size_t cr, cc;
+ caret_phys(ed.text, ed.caret, w, &cr, &cc);
+ if (cr < er) {
+ for (size_t i = 0; i < er - cr; i++)
+ out_str(ESC_CURSOR_DOWN(1));
+ } else if (cr > er) {
+ for (size_t i = 0; i < cr - er; i++)
+ out_str(ESC_CURSOR_PREVIOUS(1));
+ }
+ if (cc < ec) {
+ for (size_t i = 0; i < ec - cc; i++)
+ out_str(ESC_CURSOR_RIGHT(1));
+ } else if (cc > ec) {
+ for (size_t i = 0; i < cc - ec; i++)
+ out_str(ESC_CURSOR_LEFT(1));
+ }
+ }
+ out_str("\n");
+ editor_clear(&ed);
+ if (t[0] == '/') {
+ if (handle_command(s, dot, t + 1, reader)) {
+ running = false;
+ clean_exit = true; /* Enter-`\n` already emitted */
+ } else {
+ /* Every command's output ends in `\n`, and the
+ * next prompt draw erases the line before
+ * printing (`/clear` repositions to the
+ * top-left). */
+ }
+ } else {
+ /* The pacer terminates the turn's line with "\n";
+ * the next prompt draw erases before printing. */
+ run_turn_interactive(s, t, reader);
+ }
+ leave = true;
+ }
+ free(text);
+ break;
+ }
+ }
+ if (leave)
+ break;
+ }
+ }
+
+ /* Join any in-flight refresh (a /model switch) before the session is
+ * freed — the worker reads it. */
+ banner_worker_join();
+ if (shell) {
+ adopt_shell_cwd(s, shell);
+ shell_free(shell);
+ shell = NULL;
+ }
+ editor_free(&shell_shadow);
+ input_key_reader_stop(reader);
+ input_key_reader_free(reader); /* frees ctx and parser too */
+ editor_free(&ed);
+ input_history_free(hist);
+ input_teardown_terminal();
+ if (!clean_exit)
+ out_str("\n");
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* One-shot */
+/* ------------------------------------------------------------------ */
+
+static int one_shot(session *s, const char *prompt) {
+ std::atomic<bool> stop(false);
+ char *err = NULL;
+ int result = 0;
+ int rc = run_turn_paced(s, prompt, &stop, &err, &result);
+ if (rc < 0) {
+ fprintf(stderr, "%serror: %s%s\n", RED, err ? err : "turn failed",
+ RESET);
+ free(err);
+ return 1;
+ }
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* main */
+/* ------------------------------------------------------------------ */
+
+/* Worker for the parallel startup fetch: adopt the provider-reported
+ * context window (blocking, up to the HTTP timeout). */
+static void *startup_refresh(void *arg) {
+ session_refresh_context_limit((session *)arg);
+ return NULL;
+}
+
+int main(int argc, char **argv) {
+ /* Writing to a pipe whose peer is gone (a dead MCP server, a shell that
+ * exited) must surface as EPIPE and be handled, not kill llsh with
+ * SIGPIPE. */
+ signal(SIGPIPE, SIG_IGN);
+ char *err = NULL;
+ cli cli;
+ int prc = cli_parse(argc, argv, &cli, &err);
+ if (prc == CLI_HELP) {
+ cli_free(&cli);
+ return 0;
+ }
+ if (prc < 0) {
+ fprintf(stderr, "%s%s%s\n", RED, err ? err : "bad arguments", RESET);
+ free(err);
+ cli_free(&cli);
+ return 2;
+ }
+
+ session *s = NULL;
+ llsh_model *main_model = NULL;
+ llsh_model *summarizer = NULL;
+ llsh_model *evaluator = NULL;
+ llsh_model *rubric = NULL;
+ mcp_registry mcp;
+ bool have_mcp = false;
+ int ret = 0;
+
+ dotagents dot;
+ if (cli.resume && cli.resume[0]) {
+ session_snapshot *snap = session_load(cli.resume, &err);
+ if (!snap) {
+ fprintf(stderr, "%sresuming session from %s: %s%s\n", RED,
+ cli.resume, err ? err : "failed", RESET);
+ free(err);
+ cli_free(&cli);
+ return 1;
+ }
+ char *cwd = xstrdup(snap->cwd);
+ unsigned long count = (unsigned long) snap->hist.msgs.size();
+ char *model = xstrdup(snap->cfg.model ? snap->cfg.model : "");
+ if (chdir(cwd) != 0) {
+ fprintf(stderr, "entering saved working directory `%s`: %s\n",
+ cwd, strerror(errno));
+ free(cwd);
+ free(model);
+ session_snapshot_free(snap);
+ cli_free(&cli);
+ return 1;
+ }
+ free(cwd);
+ dotagents_load(&dot);
+ strlist mcp_warnings;
+ sl_init(&mcp_warnings);
+ if (dot.mcp_count && cli.allow_mcp) {
+ mcp_connect_all((const char *const *)dot.mcp_names,
+ (const char *const *)dot.mcp_configs,
+ dot.mcp_count, &mcp, &mcp_warnings);
+ have_mcp = true;
+ } else if (dot.mcp_count) {
+ fprintf(stderr, "warning: %zu MCP server(s) not started; pass "
+ "--allow-mcp to trust .agents/mcp.json\n",
+ dot.mcp_count);
+ }
+ for (size_t i = 0; i < mcp_warnings.len; i++)
+ fprintf(stderr, "warning: %s\n", mcp_warnings.items[i]);
+ sl_free(&mcp_warnings);
+
+ main_model = provider_model_new(NULL, have_mcp ? &mcp : NULL,
+ PROV_ROLE_MAIN);
+ s = session_resume(snap, dot, main_model, &err);
+ if (!s) {
+ fprintf(stderr, "resume failed: %s\n", err ? err : "unknown");
+ free(err);
+ llsh_model_free(main_model);
+ if (have_mcp)
+ mcp_registry_free(&mcp);
+ cli_free(&cli);
+ return 1;
+ }
+ provider_model_attach(main_model, s);
+ summarizer = provider_model_new(s, NULL, PROV_ROLE_SUMMARIZER);
+ evaluator = provider_model_new(s, NULL, PROV_ROLE_EVALUATOR);
+ rubric = provider_model_new(s, NULL, PROV_ROLE_RUBRIC);
+ session_with_summarizer(s, summarizer);
+ session_with_goal_evaluator(s, evaluator);
+ session_with_rubric(s, rubric);
+ fprintf(stdout, "resumed session: %s — %lu message(s) in context\n",
+ model, count);
+ free(model);
+ } else {
+ config cfg;
+ if (config_from_cli(&cli, &cfg, &err) != 0) {
+ fprintf(stderr, "%sinvalid configuration: %s%s\n", RED,
+ err ? err : "unknown", RESET);
+ free(err);
+ cli_free(&cli);
+ return 2;
+ }
+ dotagents_load(&dot);
+ bool preset_applied = false;
+ if (dot.has_models && dot.models.default_name &&
+ dot.models.default_name[0]) {
+ char *preset = config_apply_models_preset(&cfg, &dot.models, &cli,
+ &err);
+ if (!preset) {
+ fprintf(stderr,
+ "%sapplying .agents models.json default preset: %s%s\n",
+ RED, err ? err : "failed", RESET);
+ free(err);
+ config_free(&cfg);
+ dotagents_free(&dot);
+ cli_free(&cli);
+ return 2;
+ }
+ free(preset);
+ preset_applied = true;
+ }
+ if (!preset_applied && config_require_key_or_base(&cfg, &err) != 0) {
+ fprintf(stderr, "%s%s%s\n", RED, err ? err : "missing API key",
+ RESET);
+ free(err);
+ config_free(&cfg);
+ dotagents_free(&dot);
+ cli_free(&cli);
+ return 2;
+ }
+ strlist mcp_warnings;
+ sl_init(&mcp_warnings);
+ if (dot.mcp_count && cli.allow_mcp) {
+ mcp_connect_all((const char *const *)dot.mcp_names,
+ (const char *const *)dot.mcp_configs,
+ dot.mcp_count, &mcp, &mcp_warnings);
+ have_mcp = true;
+ } else if (dot.mcp_count) {
+ fprintf(stderr, "warning: %zu MCP server(s) not started; pass "
+ "--allow-mcp to trust .agents/mcp.json\n",
+ dot.mcp_count);
+ }
+ for (size_t i = 0; i < mcp_warnings.len; i++)
+ fprintf(stderr, "warning: %s\n", mcp_warnings.items[i]);
+ sl_free(&mcp_warnings);
+
+ main_model = provider_model_new(NULL, have_mcp ? &mcp : NULL,
+ PROV_ROLE_MAIN);
+ s = session_new_with_model(main_model, cfg, dot);
+ provider_model_attach(main_model, s);
+ summarizer = provider_model_new(s, NULL, PROV_ROLE_SUMMARIZER);
+ evaluator = provider_model_new(s, NULL, PROV_ROLE_EVALUATOR);
+ rubric = provider_model_new(s, NULL, PROV_ROLE_RUBRIC);
+ session_with_summarizer(s, summarizer);
+ session_with_goal_evaluator(s, evaluator);
+ session_with_rubric(s, rubric);
+ }
+
+ /* Wire process-wide behavior from the resolved config. */
+ const config *cfg = session_config(s);
+ verbose_set(cfg->verbose);
+ if (cli.debug)
+ session_set_debug_path(s, xstrdup("debug.json"));
+ char *mname = session_model(s);
+ verbose_log("startup",
+ (vlog_kv[]){ VLOG_STR("model", mname ? mname : "") }, 1);
+ free(mname);
+ tool_limits tl;
+ tool_limits_defaults(&tl);
+ tl.shell_timeout_secs = cfg->shell_timeout_secs;
+ tl.max_output_chars = cfg->max_output_chars;
+ tl.max_file_bytes = cfg->max_file_bytes;
+ const strlist *allow = cli.shell_allowlist.len
+ ? &cli.shell_allowlist
+ : &cfg->shell_allowlist;
+ const strlist *deny = cli.shell_denylist.len
+ ? &cli.shell_denylist
+ : &cfg->shell_denylist;
+ for (size_t i = 0; i < allow->len; i++)
+ sl_push_copy(&tl.shell_allowlist, allow->items[i]);
+ for (size_t i = 0; i < deny->len; i++)
+ sl_push_copy(&tl.shell_denylist, deny->items[i]);
+ tl.confirm_destructive = cli.has_confirm_destructive
+ ? cli.confirm_destructive
+ : cfg->confirm_destructive;
+ tl.verbose = cfg->verbose;
+ tool_limits_set(&tl);
+
+ /* Adopt the provider-reported context window before the banner is
+ * printed, so the `context window:` line is correct the first time. In
+ * the REPL this blocks (up to the HTTP timeout); in one-shot mode it
+ * runs in a thread so it overlaps the connectivity probe below. */
+ if (cli.prompt == NULL && isatty(STDIN_FILENO)) {
+ session_refresh_context_limit(s);
+ } else {
+ pthread_t refresh_th;
+ int refresh_rc = pthread_create(&refresh_th, NULL, startup_refresh, s);
+ if (refresh_rc != 0)
+ session_refresh_context_limit(s);
+ if (refresh_rc == 0)
+ pthread_join(refresh_th, NULL);
+ }
+
+ /* Connectivity probe. */
+ provider_probe probe = agent_probe_provider(session_config(s));
+ if (!probe.ok) {
+ fprintf(stderr, "warning: provider check failed: %s\n"
+ " check the provider, model, base_url, and "
+ "api_key; the first prompt may fail.\n",
+ probe.detail ? probe.detail : "");
+ }
+ provider_probe_free(&probe);
+
+ /* One-shot vs REPL. */
+ char *piped_prompt = NULL;
+ const char *one_shot_prompt = cli.prompt;
+ if (!one_shot_prompt && !isatty(STDIN_FILENO)) {
+ xbuf piped;
+ xbuf_init(&piped);
+ char buf[4096];
+ size_t n;
+ while ((n = read(STDIN_FILENO, buf, sizeof buf)) > 0)
+ xbuf_putn(&piped, buf, n);
+ char *s2 = xbuf_steal(&piped);
+ xbuf_free(&piped);
+ size_t l = strlen(s2);
+ while (l && isspace((unsigned char)s2[l - 1]))
+ s2[--l] = '\0';
+ if (l) {
+ piped_prompt = s2;
+ one_shot_prompt = s2;
+ } else {
+ free(s2);
+ }
+ }
+
+ if (one_shot_prompt && one_shot_prompt[0]) {
+ ret = one_shot(s, one_shot_prompt);
+ } else {
+ ret = repl(s, session_dot(s));
+ }
+ free(piped_prompt);
+
+ /* Join any background context-window refresh worker (e.g. left over
+ * from a /model switch) before the session is freed — it reads it. */
+ banner_worker_join();
+
+ /* --debug: one last dump so the file reflects /undo, /clear, and any
+ * post-turn bookkeeping, not just the state at the last turn boundary. */
+ if (s && cli.debug) {
+ char *derr = NULL;
+ if (session_debug_dump(s, &derr) == 0) {
+ fprintf(stderr, "[debug] wrote debug.json (%zu message(s))\n",
+ session_message_count(s));
+ } else {
+ fprintf(stderr, "debug: %s\n", derr ? derr : "dump failed");
+ }
+ free(derr);
+ }
+
+ /* Cleanup: session_free owns the models it adopted. */
+ if (s)
+ session_free(s);
+ else {
+ llsh_model_free(main_model);
+ llsh_model_free(summarizer);
+ llsh_model_free(evaluator);
+ llsh_model_free(rubric);
+ }
+ if (have_mcp)
+ mcp_registry_free(&mcp);
+ cli_free(&cli);
+ return ret;
+}
diff --git a/src/mcp.cpp b/src/mcp.cpp
new file mode 100644
index 0000000..94615a3
--- /dev/null
+++ b/src/mcp.cpp
@@ -0,0 +1,1010 @@
+#include "mcp.h"
+
+#include "config.h" /* LLSH_VERSION */
+
+#include <cjson/cJSON.h>
+#include <curl/curl.h>
+
+#include <ctype.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#define MCP_MAX_MESSAGE_BYTES (4u * 1024u * 1024u)
+
+typedef struct {
+ xbuf *buf;
+ size_t cap;
+} mcp_write_ctx;
+
+static size_t mcp_curl_write(char *data, size_t size, size_t nmemb, void *ud) {
+ mcp_write_ctx *ctx = (mcp_write_ctx *)ud;
+ if (size && nmemb > SIZE_MAX / size)
+ return 0;
+ size_t n = size * nmemb;
+ if (n > ctx->cap - ctx->buf->len)
+ return 0;
+ xbuf_putn(ctx->buf, data, n);
+ return n;
+}
+
+/* ------------------------------------------------------------------ */
+/* config parsing */
+/* ------------------------------------------------------------------ */
+
+static char *jstr(const cJSON *v, const char *key)
+{
+ const cJSON *x = cJSON_GetObjectItemCaseSensitive(v, key);
+ if (cJSON_IsString(x) && x->valuestring)
+ return xstrdup(x->valuestring);
+ return NULL;
+}
+
+static void jargs(const cJSON *v, mcp_server_config *out)
+{
+ const cJSON *a = cJSON_GetObjectItemCaseSensitive(v, "args");
+ if (!cJSON_IsArray(a))
+ return;
+ size_t n = (size_t)cJSON_GetArraySize(a);
+ char **arr = (char* * ) xmalloc((n ? n : 1) * sizeof(*arr));
+ size_t used = 0;
+ const cJSON *x = NULL;
+ cJSON_ArrayForEach(x, a)
+ if (cJSON_IsString(x) && x->valuestring)
+ arr[used++] = xstrdup(x->valuestring);
+ out->args = arr;
+ out->arg_count = used;
+}
+
+static void jkv(const cJSON *v, const char *key, mcp_kv **out, size_t *n)
+{
+ const cJSON *o = cJSON_GetObjectItemCaseSensitive(v, key);
+ if (!cJSON_IsObject(o))
+ return;
+ size_t nitems = (size_t)cJSON_GetArraySize(o);
+ mcp_kv *arr = (mcp_kv *) xmalloc((nitems ? nitems : 1) * sizeof(*arr));
+ size_t used = 0;
+ const cJSON *child = o->child;
+ while (child) {
+ if (child->string && cJSON_IsString(child) && child->valuestring) {
+ arr[used].key = xstrdup(child->string);
+ arr[used].val = xstrdup(child->valuestring);
+ used++;
+ }
+ child = child->next;
+ }
+ *out = arr;
+ *n = used;
+}
+
+int mcp_server_config_from_json(const char *json, mcp_server_config *out)
+{
+ memset(out, 0, sizeof(*out));
+ cJSON *v = json ? cJSON_Parse(json) : NULL;
+ if (!v || !cJSON_IsObject(v)) {
+ if (v)
+ cJSON_Delete(v);
+ return 0;
+ }
+ out->command = jstr(v, "command");
+ out->url = jstr(v, "url");
+ if (!out->command && !out->url) {
+ free(out->command);
+ free(out->url);
+ cJSON_Delete(v);
+ return 0;
+ }
+ jargs(v, out);
+ jkv(v, "env", &out->env, &out->env_count);
+ jkv(v, "headers", &out->headers, &out->header_count);
+ cJSON_Delete(v);
+ return 1;
+}
+
+void mcp_server_config_free(mcp_server_config *c)
+{
+ if (!c)
+ return;
+ free(c->command);
+ free(c->url);
+ for (size_t i = 0; i < c->arg_count; i++)
+ free(c->args[i]);
+ free(c->args);
+ for (size_t i = 0; i < c->env_count; i++) {
+ free(c->env[i].key);
+ free(c->env[i].val);
+ }
+ free(c->env);
+ for (size_t i = 0; i < c->header_count; i++) {
+ free(c->headers[i].key);
+ free(c->headers[i].val);
+ }
+ free(c->headers);
+ memset(c, 0, sizeof(*c));
+}
+
+/* ------------------------------------------------------------------ */
+/* client */
+/* ------------------------------------------------------------------ */
+
+enum { T_STDIO, T_HTTP, T_MEMORY };
+
+struct mcp_client {
+ int kind;
+ unsigned long long next_id;
+ /* stdio */
+ pid_t pid;
+ int in_fd;
+ int out_fd;
+ char *rb; /* carry-over bytes between line reads (partial line) */
+ size_t rb_len;
+ /* http */
+ char *url;
+ mcp_kv *headers;
+ size_t header_count;
+ /* memory */
+ mcp_memory *mem;
+};
+
+long mcp_timeout_secs(void)
+{
+ const char *e = getenv("LLSH_MCP_TIMEOUT");
+ if (e && e[0]) {
+ errno = 0;
+ char *end = NULL;
+ long v = strtol(e, &end, 10);
+ if (errno == 0 && end != e && *end == '\0' && v > 0) {
+ if (v > 2147483L)
+ return 2147483L; /* poll timeout is milliseconds in an int */
+ return v;
+ }
+ }
+ return MCP_TIMEOUT_SECS;
+}
+
+void mcp_memory_init(mcp_memory *m)
+{
+ sl_init(&m->responses);
+ sl_init(&m->sent);
+}
+
+mcp_client *mcp_connect_stdio(const mcp_server_config *cfg, char **err)
+{
+ if (err)
+ *err = NULL;
+ if (!cfg->command) {
+ if (err)
+ *err = xstrdup("stdio server has no `command`");
+ return NULL;
+ }
+ int in_pipe[2] = {-1, -1}, out_pipe[2] = {-1, -1};
+ if (pipe(in_pipe) != 0 || pipe(out_pipe) != 0) {
+ if (err)
+ *err = xstrdup("i/o: pipe failed");
+ if (in_pipe[0] >= 0) close(in_pipe[0]);
+ if (in_pipe[1] >= 0) close(in_pipe[1]);
+ if (out_pipe[0] >= 0) close(out_pipe[0]);
+ if (out_pipe[1] >= 0) close(out_pipe[1]);
+ return NULL;
+ }
+ /* Heap-allocate argv (a VLA sized by config data can overflow the stack
+ * for a large `args` array). Allocated pre-fork so the child inherits it. */
+ char **argv = (char* * ) xmalloc((cfg->arg_count + 2) * sizeof *argv);
+ argv[0] = cfg->command;
+ for (size_t i = 0; i < cfg->arg_count; i++)
+ argv[i + 1] = cfg->args[i];
+ argv[cfg->arg_count + 1] = NULL;
+ pid_t pid = fork();
+ if (pid < 0) {
+ free(argv);
+ if (err)
+ *err = xstrdup("i/o: fork failed");
+ close(in_pipe[0]);
+ close(in_pipe[1]);
+ close(out_pipe[0]);
+ close(out_pipe[1]);
+ return NULL;
+ }
+ if (pid == 0) {
+ dup2(in_pipe[0], STDIN_FILENO);
+ dup2(out_pipe[1], STDOUT_FILENO);
+ close(in_pipe[0]);
+ close(in_pipe[1]);
+ close(out_pipe[0]);
+ close(out_pipe[1]);
+ int dn = open("/dev/null", O_WRONLY);
+ if (dn >= 0) {
+ dup2(dn, STDERR_FILENO);
+ close(dn);
+ }
+ for (size_t i = 0; i < cfg->env_count; i++)
+ setenv(cfg->env[i].key, cfg->env[i].val, 1);
+ /* Own process group so freeing the client can kill the whole tree. */
+ setpgid(0, 0);
+ execvp(cfg->command, argv);
+ _exit(127);
+ }
+ free(argv);
+ close(in_pipe[0]);
+ close(out_pipe[1]);
+ mcp_client *c = (mcp_client *) xmalloc(sizeof(*c));
+ memset(c, 0, sizeof(*c));
+ c->kind = T_STDIO;
+ c->pid = pid;
+ c->in_fd = in_pipe[1];
+ c->out_fd = out_pipe[0];
+ return c;
+}
+
+mcp_client *mcp_connect_http(const mcp_server_config *cfg, char **err)
+{
+ if (err)
+ *err = NULL;
+ if (!cfg->url) {
+ if (err)
+ *err = xstrdup("HTTP server has no `url`");
+ return NULL;
+ }
+ mcp_client *c = (mcp_client *) xmalloc(sizeof(*c));
+ memset(c, 0, sizeof(*c));
+ c->kind = T_HTTP;
+ c->url = xstrdup(cfg->url);
+ if (cfg->header_count) {
+ c->headers = (mcp_kv *) xmalloc(cfg->header_count * sizeof(*c->headers));
+ memcpy(c->headers, cfg->headers,
+ cfg->header_count * sizeof(*c->headers));
+ for (size_t i = 0; i < cfg->header_count; i++) {
+ c->headers[i].key = xstrdup(cfg->headers[i].key);
+ c->headers[i].val = xstrdup(cfg->headers[i].val);
+ }
+ c->header_count = cfg->header_count;
+ }
+ return c;
+}
+
+/* Test-only: wrap already-open stdio fds (the client closes the two it is
+ * given on free; no child process is reaped). */
+mcp_client *mcp_connect_fds(int in_fd, int out_fd, char **err)
+{
+ if (err)
+ *err = NULL;
+ mcp_client *c = (mcp_client *) xmalloc(sizeof(*c));
+ memset(c, 0, sizeof(*c));
+ c->kind = T_STDIO;
+ c->in_fd = in_fd;
+ c->out_fd = out_fd;
+ return c;
+}
+
+mcp_client *mcp_connect_memory(mcp_memory *mem, char **err)
+{
+ if (err)
+ *err = NULL;
+ mcp_client *c = (mcp_client *) xmalloc(sizeof(*c));
+ memset(c, 0, sizeof(*c));
+ c->kind = T_MEMORY;
+ c->mem = mem;
+ return c;
+}
+
+void mcp_client_free(mcp_client *c)
+{
+ if (!c)
+ return;
+ if (c->kind == T_STDIO) {
+ if (c->in_fd >= 0)
+ close(c->in_fd);
+ if (c->out_fd >= 0)
+ close(c->out_fd);
+ free(c->rb);
+ if (c->pid > 0) {
+ int st = 0;
+ pid_t r = waitpid(c->pid, &st, WNOHANG);
+ if (r == 0) {
+ /* Kill the whole process group so grandchildren don't
+ * survive. */
+ kill(-c->pid, SIGTERM);
+ for (int i = 0; i < 50 &&
+ (r = waitpid(c->pid, &st, WNOHANG)) == 0;
+ i++)
+ usleep(10000);
+ if (r == 0) {
+ kill(-c->pid, SIGKILL);
+ waitpid(c->pid, &st, 0);
+ }
+ }
+ }
+ } else if (c->kind == T_HTTP) {
+ free(c->url);
+ for (size_t i = 0; i < c->header_count; i++) {
+ free(c->headers[i].key);
+ free(c->headers[i].val);
+ }
+ free(c->headers);
+ }
+ /* T_MEMORY: the mcp_memory belongs to the caller; we only borrowed it. */
+ free(c);
+}
+
+/* ------------------------------------------------------------------ */
+/* transports */
+/* ------------------------------------------------------------------ */
+
+static int write_all_fd(int fd, const char *data, size_t len)
+{
+ size_t off = 0;
+ while (off < len) {
+ ssize_t w = write(fd, data + off, len - off);
+ if (w < 0) {
+ if (errno == EINTR)
+ continue;
+ return -1;
+ }
+ off += (size_t)w;
+ }
+ return 0;
+}
+
+/* One line (no trailing newline) or NULL on EOF/timeout; *err on failure.
+ * Reads in chunks and carries any partial line in c->rb across calls, so a
+ * burst of lines in one read() is handled without per-byte syscalls. */
+static char *read_line_fd(mcp_client *c, const char *method, char **err)
+{
+ long tmo = mcp_timeout_secs();
+ char *out = NULL;
+ for (;;) {
+ for (size_t i = 0; i < c->rb_len; i++) {
+ if (c->rb[i] == '\n') {
+ out = xstrndup(c->rb, i);
+ size_t rest = c->rb_len - i - 1;
+ memmove(c->rb, c->rb + i + 1, rest);
+ c->rb_len = rest;
+ goto done;
+ }
+ }
+ struct pollfd p = { .fd = c->out_fd, .events = POLLIN };
+ int pr = poll(&p, 1, (int)(tmo * 1000));
+ if (pr < 0) {
+ if (errno == EINTR)
+ continue;
+ if (err)
+ *err = xstrdup("i/o: read failed");
+ return NULL;
+ }
+ if (pr == 0) {
+ if (c->rb_len == 0) {
+ if (err)
+ *err = xstrfmt("request `%s` timed out after %lds", method,
+ tmo);
+ } else {
+ out = xstrndup(c->rb, c->rb_len);
+ c->rb_len = 0;
+ }
+ goto done;
+ }
+ char tmp[64 * 1024];
+ ssize_t r = read(c->out_fd, tmp, sizeof tmp);
+ if (r < 0) {
+ if (errno == EINTR)
+ continue;
+ break;
+ }
+ if (r == 0)
+ break;
+ if ((size_t)r > MCP_MAX_MESSAGE_BYTES - c->rb_len) {
+ free(c->rb);
+ c->rb = NULL;
+ c->rb_len = 0;
+ if (err)
+ *err = xstrdup("MCP stdio response line exceeds 4 MiB limit");
+ return NULL;
+ }
+ char *nb = (char *) xrealloc(c->rb, c->rb_len + (size_t)r);
+ memcpy(nb + c->rb_len, tmp, (size_t)r);
+ c->rb = nb;
+ c->rb_len += (size_t)r;
+ }
+ if (c->rb_len > 0) {
+ out = xstrndup(c->rb, c->rb_len);
+ c->rb_len = 0;
+ }
+ done:
+ if (out) {
+ size_t n = strlen(out);
+ if (n > 0 && out[n - 1] == '\r')
+ out[n - 1] = '\0';
+ }
+ if (!out || out[0] == '\0') {
+ free(out);
+ /* Keep a more specific error already set (e.g. timeout). */
+ if (err && !*err)
+ *err = xstrdup("server closed the connection before answering");
+ return NULL;
+ }
+ return out;
+}
+
+/* Test-only: one buffered line read with carry semantics (read_line_fd). */
+char *mcp_read_line(mcp_client *c, const char *method, char **err)
+{
+ return read_line_fd(c, method, err);
+}
+
+static int rt_stdio(mcp_client *c, const char *line, unsigned long long id,
+ const char *method, cJSON **resp, char **err)
+{
+ if (write_all_fd(c->in_fd, line, strlen(line)) != 0 ||
+ write_all_fd(c->in_fd, "\n", 1) != 0) {
+ if (err)
+ *err = xstrdup("i/o: write failed");
+ return -1;
+ }
+ for (;;) {
+ char *l = read_line_fd(c, method, err);
+ if (!l)
+ return -1;
+ size_t tlen;
+ const char *t = xtrim_span(l, strlen(l), &tlen);
+ if (tlen == 0) {
+ free(l);
+ continue;
+ }
+ cJSON *v = cJSON_Parse(t);
+ free(l);
+ if (!v) {
+ if (err)
+ *err = xstrdup("json: invalid message from server");
+ return -1;
+ }
+ cJSON *idv = cJSON_GetObjectItemCaseSensitive(v, "id");
+ if (cJSON_IsNumber(idv) &&
+ (unsigned long long)idv->valuedouble == id &&
+ (cJSON_HasObjectItem(v, "result") || cJSON_HasObjectItem(v, "error"))) {
+ *resp = v;
+ return 0;
+ }
+ cJSON_Delete(v); /* notification or unrelated message: skip */
+ }
+}
+
+static int rt_http(mcp_client *c, const char *json, unsigned long long id,
+ cJSON **resp, char **err)
+{
+ CURL *e = curl_easy_init();
+ if (!e) {
+ if (err)
+ *err = xstrdup("http: could not initialize curl");
+ return -1;
+ }
+ xbuf body;
+ xbuf_init(&body);
+ struct curl_slist *hl = curl_slist_append(NULL, "Content-Type: application/json");
+ for (size_t i = 0; i < c->header_count; i++) {
+ xbuf hb;
+ xbuf_init(&hb);
+ xbuf_fmt(&hb, "%s: %s", c->headers[i].key, c->headers[i].val);
+ hl = curl_slist_append(hl, hb.data);
+ xbuf_free(&hb);
+ }
+ curl_easy_setopt(e, CURLOPT_URL, c->url);
+ curl_easy_setopt(e, CURLOPT_POST, 1L);
+ curl_easy_setopt(e, CURLOPT_POSTFIELDS, json);
+ curl_easy_setopt(e, CURLOPT_TIMEOUT, mcp_timeout_secs());
+ curl_easy_setopt(e, CURLOPT_NOSIGNAL, 1L);
+ const char *ca = llsh_ca_bundle();
+ if (ca)
+ curl_easy_setopt(e, CURLOPT_CAINFO, ca);
+ curl_easy_setopt(e, CURLOPT_HTTPHEADER, hl);
+ mcp_write_ctx write_ctx = {&body, MCP_MAX_MESSAGE_BYTES};
+ curl_easy_setopt(e, CURLOPT_WRITEFUNCTION, mcp_curl_write);
+ curl_easy_setopt(e, CURLOPT_WRITEDATA, &write_ctx);
+ CURLcode rc = curl_easy_perform(e);
+ if (rc != CURLE_OK) {
+ char *e2 = xstrfmt("http: %s", curl_easy_strerror(rc));
+ free(body.data);
+ curl_slist_free_all(hl);
+ curl_easy_cleanup(e);
+ if (err)
+ *err = e2;
+ return -1;
+ }
+ long code = 0;
+ curl_easy_getinfo(e, CURLINFO_RESPONSE_CODE, &code);
+ if (code >= 400) {
+ char *e2 = xstrfmt("HTTP %ld", code);
+ free(body.data);
+ curl_slist_free_all(hl);
+ curl_easy_cleanup(e);
+ if (err)
+ *err = e2;
+ return -1;
+ }
+ char *ct = NULL;
+ curl_easy_getinfo(e, CURLINFO_CONTENT_TYPE, &ct);
+ char *body_text = xbuf_steal(&body);
+ curl_slist_free_all(hl);
+ curl_easy_cleanup(e);
+
+ char *local_err = NULL;
+ char *json_text = mcp_extract_json(ct ? ct : "", body_text, &local_err);
+ free(body_text);
+ if (!json_text) {
+ if (err)
+ *err = local_err;
+ else
+ free(local_err);
+ return -1;
+ }
+ cJSON *v = cJSON_Parse(json_text);
+ free(json_text);
+ if (!v) {
+ if (err)
+ *err = xstrdup("json: invalid HTTP body");
+ return -1;
+ }
+ cJSON *idv = cJSON_GetObjectItemCaseSensitive(v, "id");
+ if (!cJSON_IsNumber(idv) || (unsigned long long)idv->valuedouble != id) {
+ cJSON_Delete(v);
+ if (err)
+ *err = xstrdup("HTTP response id does not match the request");
+ return -1;
+ }
+ *resp = v;
+ return 0;
+}
+
+static int rt_memory(mcp_client *c, const char *line, unsigned long long id,
+ cJSON **resp, char **err)
+{
+ sl_push_copy(&c->mem->sent, line);
+ for (;;) {
+ char *queued = sl_pop_front(&c->mem->responses);
+ if (!queued) {
+ if (err)
+ *err = xstrdup("no queued response (test transport exhausted)");
+ return -1;
+ }
+ cJSON *v = cJSON_Parse(queued);
+ free(queued);
+ if (!v)
+ continue;
+ cJSON *idv = cJSON_GetObjectItemCaseSensitive(v, "id");
+ if (cJSON_IsNumber(idv) && (unsigned long long)idv->valuedouble == id &&
+ (cJSON_HasObjectItem(v, "result") || cJSON_HasObjectItem(v, "error"))) {
+ *resp = v;
+ return 0;
+ }
+ cJSON_Delete(v);
+ }
+}
+
+static int roundtrip(mcp_client *c, const char *line, unsigned long long id,
+ const char *method, cJSON **resp, char **err)
+{
+ switch (c->kind) {
+ case T_STDIO:
+ return rt_stdio(c, line, id, method, resp, err);
+ case T_HTTP:
+ return rt_http(c, line, id, resp, err);
+ default:
+ return rt_memory(c, line, id, resp, err);
+ }
+}
+
+static int notify_rt(mcp_client *c, const char *line, char **err)
+{
+ switch (c->kind) {
+ case T_STDIO: {
+ int rc = write_all_fd(c->in_fd, line, strlen(line));
+ if (rc == 0)
+ rc = write_all_fd(c->in_fd, "\n", 1);
+ if (rc != 0 && err)
+ *err = xstrdup("i/o: write failed");
+ return rc;
+ }
+ case T_HTTP: {
+ CURL *e = curl_easy_init();
+ if (!e) {
+ if (err)
+ *err = xstrdup("http: could not initialize curl");
+ return -1;
+ }
+ struct curl_slist *hl = curl_slist_append(NULL, "Content-Type: application/json");
+ for (size_t i = 0; i < c->header_count; i++) {
+ xbuf hb;
+ xbuf_init(&hb);
+ xbuf_fmt(&hb, "%s: %s", c->headers[i].key, c->headers[i].val);
+ hl = curl_slist_append(hl, hb.data);
+ xbuf_free(&hb);
+ }
+ curl_easy_setopt(e, CURLOPT_URL, c->url);
+ curl_easy_setopt(e, CURLOPT_POST, 1L);
+ curl_easy_setopt(e, CURLOPT_POSTFIELDS, line);
+ curl_easy_setopt(e, CURLOPT_TIMEOUT, mcp_timeout_secs());
+ curl_easy_setopt(e, CURLOPT_NOSIGNAL, 1L);
+ const char *ca = llsh_ca_bundle();
+ if (ca)
+ curl_easy_setopt(e, CURLOPT_CAINFO, ca);
+ curl_easy_setopt(e, CURLOPT_HTTPHEADER, hl);
+ xbuf sink;
+ xbuf_init(&sink);
+ mcp_write_ctx write_ctx = {&sink, MCP_MAX_MESSAGE_BYTES};
+ curl_easy_setopt(e, CURLOPT_WRITEFUNCTION, mcp_curl_write);
+ curl_easy_setopt(e, CURLOPT_WRITEDATA, &write_ctx);
+ CURLcode rc = curl_easy_perform(e);
+ free(sink.data);
+ curl_slist_free_all(hl);
+ curl_easy_cleanup(e);
+ if (rc != CURLE_OK) {
+ if (err)
+ *err = xstrfmt("http: %s", curl_easy_strerror(rc));
+ return -1;
+ }
+ return 0;
+ }
+ default:
+ sl_push_copy(&c->mem->sent, line);
+ return 0;
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* JSON-RPC layer */
+/* ------------------------------------------------------------------ */
+
+char *mcp_request(mcp_client *c, const char *method, const char *params_json,
+ char **err)
+{
+ if (err)
+ *err = NULL;
+ unsigned long long id = ++c->next_id;
+ cJSON *req = cJSON_CreateObject();
+ cJSON_AddStringToObject(req, "jsonrpc", "2.0");
+ cJSON_AddNumberToObject(req, "id", (double)id);
+ cJSON_AddStringToObject(req, "method", method);
+ cJSON *params = params_json ? cJSON_Parse(params_json) : cJSON_CreateObject();
+ if (!params)
+ params = cJSON_CreateObject();
+ cJSON_AddItemToObject(req, "params", params);
+ char *line = cJSON_PrintUnformatted(req);
+ cJSON_Delete(req);
+ cJSON *resp = NULL;
+ int rc = roundtrip(c, line, id, method, &resp, err);
+ free(line);
+ if (rc != 0)
+ return NULL;
+
+ cJSON *e = cJSON_GetObjectItemCaseSensitive(resp, "error");
+ if (e && cJSON_IsObject(e)) {
+ cJSON *code = cJSON_GetObjectItemCaseSensitive(e, "code");
+ char *code_text = code ? cJSON_PrintUnformatted(code) : xstrdup("null");
+ cJSON *msg = cJSON_GetObjectItemCaseSensitive(e, "message");
+ char *out = xstrfmt("server error %s: %s", code_text,
+ cJSON_IsString(msg) && msg->valuestring
+ ? msg->valuestring : "unknown error");
+ free(code_text);
+ cJSON_Delete(resp);
+ if (err)
+ *err = out;
+ else
+ free(out);
+ return NULL;
+ }
+ cJSON *result = cJSON_GetObjectItemCaseSensitive(resp, "result");
+ char *out = result ? cJSON_PrintUnformatted(result) : xstrdup("null");
+ cJSON_Delete(resp);
+ return out;
+}
+
+int mcp_notify(mcp_client *c, const char *method, const char *params_json,
+ char **err)
+{
+ if (err)
+ *err = NULL;
+ cJSON *req = cJSON_CreateObject();
+ cJSON_AddStringToObject(req, "jsonrpc", "2.0");
+ cJSON_AddStringToObject(req, "method", method);
+ cJSON *params = params_json ? cJSON_Parse(params_json) : cJSON_CreateObject();
+ if (!params)
+ params = cJSON_CreateObject();
+ cJSON_AddItemToObject(req, "params", params);
+ char *line = cJSON_PrintUnformatted(req);
+ cJSON_Delete(req);
+ int rc = notify_rt(c, line, err);
+ free(line);
+ return rc == 0;
+}
+
+int mcp_initialize(mcp_client *c, char **err)
+{
+ char *params = xstrfmt(
+ "{\"protocolVersion\":\"%s\",\"capabilities\":{},\"clientInfo\":"
+ "{\"name\":\"llsh\",\"version\":\"%s\"}}",
+ MCP_PROTOCOL_VERSION, LLSH_VERSION);
+ char *res = mcp_request(c, "initialize", params, err);
+ free(params);
+ if (!res)
+ return 0;
+ free(res);
+ char *err2 = NULL;
+ int ok = mcp_notify(c, "notifications/initialized", "{}", &err2);
+ if (!ok) {
+ if (err && !*err)
+ *err = err2;
+ else
+ free(err2);
+ return 0;
+ }
+ return 1;
+}
+
+int mcp_list_tools(mcp_client *c, mcp_tool **out, size_t *n, char **err)
+{
+ char *res = mcp_request(c, "tools/list", "{}", err);
+ if (!res)
+ return 0;
+ cJSON *root = cJSON_Parse(res);
+ free(res);
+ if (!root) {
+ if (err)
+ *err = xstrdup("json: invalid tools/list result");
+ return 0;
+ }
+ cJSON *tools = cJSON_GetObjectItemCaseSensitive(root, "tools");
+ size_t count = cJSON_IsArray(tools) ? (size_t)cJSON_GetArraySize(tools) : 0;
+ mcp_tool *arr = (mcp_tool *) xmalloc((count ? count : 1) * sizeof(*arr));
+ size_t used = 0;
+ if (cJSON_IsArray(tools)) {
+ const cJSON *t;
+ cJSON_ArrayForEach(t, tools) {
+ cJSON *name = cJSON_GetObjectItemCaseSensitive(t, "name");
+ if (!cJSON_IsString(name) || !name->valuestring)
+ continue;
+ cJSON *desc = cJSON_GetObjectItemCaseSensitive(t, "description");
+ cJSON *schema = cJSON_GetObjectItemCaseSensitive(t, "inputSchema");
+ mcp_tool *slot = &arr[used++];
+ memset(slot, 0, sizeof *slot);
+ slot->name = xstrdup(name->valuestring);
+ slot->description = cJSON_IsString(desc) && desc->valuestring
+ ? xstrdup(desc->valuestring) : xstrdup("");
+ slot->input_schema =
+ schema ? cJSON_PrintUnformatted(schema)
+ : xstrdup("{\"type\":\"object\"}");
+ if (!slot->input_schema)
+ slot->input_schema = xstrdup("{\"type\":\"object\"}");
+ slot->client = c;
+ }
+ }
+ cJSON_Delete(root);
+ *out = arr;
+ *n = used;
+ return 1;
+}
+
+void mcp_tools_free(mcp_tool *tools, size_t n)
+{
+ if (!tools)
+ return;
+ for (size_t i = 0; i < n; i++) {
+ free(tools[i].name);
+ free(tools[i].qualified_name);
+ free(tools[i].description);
+ free(tools[i].input_schema);
+ }
+ free(tools);
+}
+
+char *mcp_call_tool(mcp_client *c, const char *name, const char *args_json,
+ char **err)
+{
+ cJSON *name_j = cJSON_CreateString(name ? name : "");
+ char *name_s = cJSON_PrintUnformatted(name_j);
+ cJSON_Delete(name_j);
+ char *params = xstrfmt("{\"name\":%s,\"arguments\":%s}",
+ name_s ? name_s : "\"\"",
+ args_json ? args_json : "{}");
+ free(name_s);
+ char *res = mcp_request(c, "tools/call", params, err);
+ free(params);
+ if (!res)
+ return NULL;
+ cJSON *root = cJSON_Parse(res);
+ free(res);
+ if (!root) {
+ if (err)
+ *err = xstrdup("json: invalid tools/call result");
+ return NULL;
+ }
+ cJSON *iserr = cJSON_GetObjectItemCaseSensitive(root, "isError");
+ bool err_flag = cJSON_IsTrue(iserr);
+ cJSON *content = cJSON_GetObjectItemCaseSensitive(root, "content");
+ xbuf text;
+ xbuf_init(&text);
+ if (cJSON_IsArray(content)) {
+ const cJSON *item;
+ cJSON_ArrayForEach(item, content) {
+ cJSON *t = cJSON_GetObjectItemCaseSensitive(item, "text");
+ if (cJSON_IsString(t) && t->valuestring) {
+ if (text.len)
+ xbuf_push(&text, '\n');
+ xbuf_put(&text, t->valuestring);
+ } else {
+ cJSON *type = cJSON_GetObjectItemCaseSensitive(item, "type");
+ cJSON *data = cJSON_GetObjectItemCaseSensitive(item, "data");
+ if ((cJSON_IsString(type) && type->valuestring &&
+ strcmp(type->valuestring, "image") == 0) || data != NULL) {
+ if (text.len)
+ xbuf_push(&text, '\n');
+ xbuf_put(&text, "[binary content]");
+ }
+ }
+ }
+ }
+ if (text.len == 0)
+ xbuf_put(&text, "(no content)");
+ char *out = xbuf_steal(&text);
+ cJSON_Delete(root);
+ if (err_flag) {
+ char *e = xstrfmt("tool `%s` reported an error: %s", name, out);
+ free(out);
+ if (err)
+ *err = e;
+ return NULL;
+ }
+ return out;
+}
+
+char *mcp_extract_json(const char *content_type, const char *body, char **err)
+{
+ if (err)
+ *err = NULL;
+ if (content_type && strstr(content_type, "event-stream")) {
+ const char *p = body ? body : "";
+ const char *best = NULL;
+ size_t best_len = 0;
+ while (*p) {
+ const char *nl = strchr(p, '\n');
+ size_t linelen = nl ? (size_t)(nl - p) : strlen(p);
+ size_t tlen;
+ const char *line = xtrim_span(p, linelen, &tlen);
+ if (tlen > 5 && strncmp(line, "data:", 5) == 0) {
+ size_t dlen;
+ const char *d = xtrim_span(line + 5, tlen - 5, &dlen);
+ if (dlen > 0) {
+ best = d;
+ best_len = dlen;
+ }
+ }
+ if (!nl)
+ break;
+ p = nl + 1;
+ }
+ if (!best) {
+ if (err)
+ *err = xstrdup("SSE body had no `data:` line");
+ return NULL;
+ }
+ char *txt = xstrndup(best, best_len);
+ cJSON *v = cJSON_Parse(txt);
+ free(txt);
+ if (!v) {
+ if (err)
+ *err = xstrdup("json: invalid SSE data line");
+ return NULL;
+ }
+ char *out = cJSON_PrintUnformatted(v);
+ cJSON_Delete(v);
+ return out;
+ }
+ cJSON *v = cJSON_Parse(body ? body : "");
+ if (!v) {
+ if (err)
+ *err = xstrdup("json: invalid HTTP body");
+ return NULL;
+ }
+ char *out = cJSON_PrintUnformatted(v);
+ cJSON_Delete(v);
+ return out;
+}
+
+/* ------------------------------------------------------------------ */
+/* registry */
+/* ------------------------------------------------------------------ */
+
+void mcp_connect_all(const char *const *names, const char *const *configs,
+ size_t n, mcp_registry *out, strlist *warnings)
+{
+ memset(out, 0, sizeof(*out));
+ mcp_tool *tools = NULL;
+ size_t tool_count = 0;
+ mcp_client **clients = NULL;
+ size_t client_count = 0;
+
+ for (size_t i = 0; i < n; i++) {
+ const char *name = names[i];
+ const char *cfg_json = configs[i];
+ mcp_server_config cfg;
+ if (!mcp_server_config_from_json(cfg_json, &cfg)) {
+ sl_push(warnings,
+ xstrfmt("mcp `%s`: no `command` or `url`; skipped", name));
+ continue;
+ }
+
+ mcp_client *c = NULL;
+ char *err = NULL;
+ if (cfg.command)
+ c = mcp_connect_stdio(&cfg, &err);
+ else
+ c = mcp_connect_http(&cfg, &err);
+ if (!c) {
+ sl_push(warnings, xstrfmt("mcp `%s`: failed to start", name));
+ free(err);
+ mcp_server_config_free(&cfg);
+ continue;
+ }
+ if (!mcp_initialize(c, &err)) {
+ sl_push(warnings,
+ xstrfmt("mcp `%s`: handshake failed: %s", name,
+ err ? err : "unknown"));
+ free(err);
+ mcp_client_free(c);
+ mcp_server_config_free(&cfg);
+ continue;
+ }
+ mcp_tool *t = NULL;
+ size_t tn = 0;
+ char *err2 = NULL;
+ if (!mcp_list_tools(c, &t, &tn, &err2)) {
+ sl_push(warnings, xstrfmt("mcp `%s`: tools/list failed", name));
+ free(err2);
+ mcp_client_free(c);
+ mcp_server_config_free(&cfg);
+ continue;
+ }
+ for (size_t j = 0; j < tn; j++) {
+ t[j].qualified_name = xstrfmt("mcp__%zu_%s__%s", strlen(name),
+ name, t[j].name);
+ }
+ tools = (mcp_tool *) xrealloc(tools, (tool_count + tn) * sizeof(*tools));
+ memcpy(tools + tool_count, t, tn * sizeof(*t));
+ tool_count += tn;
+ free(t);
+ clients =
+ (mcp_client **)xrealloc(clients, (client_count + 1) * sizeof(*clients));
+ clients[client_count++] = c;
+ mcp_server_config_free(&cfg);
+ }
+
+ out->tools = tools;
+ out->tool_count = tool_count;
+ out->clients = clients;
+ out->client_count = client_count;
+}
+
+void mcp_registry_free(mcp_registry *r)
+{
+ if (!r)
+ return;
+ mcp_tools_free(r->tools, r->tool_count);
+ for (size_t i = 0; i < r->client_count; i++)
+ mcp_client_free(r->clients[i]);
+ free(r->clients);
+ memset(r, 0, sizeof(*r));
+}
+
+char *mcp_tool_invoke(const mcp_tool *t, const char *args_json, char **err)
+{
+ char *out = mcp_call_tool(t->client, t->name, args_json, err);
+ if (out)
+ return out;
+ char *e = err ? *err : NULL;
+ if (err)
+ *err = xstrfmt("MCP tool `%s` failed: %s", t->name, e ? e : "unknown");
+ free(e);
+ return NULL;
+}
diff --git a/src/mcp.h b/src/mcp.h
new file mode 100644
index 0000000..24a35a9
--- /dev/null
+++ b/src/mcp.h
@@ -0,0 +1,112 @@
+#ifndef LLSH_MCP_H
+#define LLSH_MCP_H
+
+#include "util.h"
+
+/* Mirrors mcp.rs: a minimal Model Context Protocol client.
+ * mcpServers entries are either stdio (command/args/env) or HTTP (url/headers).
+ * Transport framing is JSON-RPC 2.0: one message per line over stdio, a single
+ * request/response (plain JSON or SSE) over HTTP. A wedged server must not hang
+ * the agent: every call is bounded by MCP_TIMEOUT_SECS. */
+
+#define MCP_TIMEOUT_SECS 120L
+#define MCP_PROTOCOL_VERSION "2024-11-05"
+
+/* Effective per-call timeout. MCP_TIMEOUT_SECS unless the LLSH_MCP_TIMEOUT
+ * environment variable sets a positive whole number of seconds. */
+long mcp_timeout_secs(void);
+
+typedef struct {
+ char *key;
+ char *val;
+} mcp_kv;
+
+/* One mcpServers entry. All fields owned (NULL/0 when absent). */
+typedef struct {
+ char *command;
+ char **args;
+ size_t arg_count;
+ mcp_kv *env;
+ size_t env_count;
+ char *url;
+ mcp_kv *headers;
+ size_t header_count;
+} mcp_server_config;
+
+typedef struct mcp_client mcp_client;
+
+/* A tool advertised by a server's tools/list. client is a back-reference. */
+typedef struct {
+ char *name; /* remote name */
+ char *qualified_name; /* exposed as mcp__<server-length>_<server>__<tool> */
+ char *description;
+ char *input_schema; /* JSON text */
+ mcp_client *client;
+} mcp_tool;
+
+/* Everything connected: the flattened tool list plus the owning clients. */
+typedef struct {
+ mcp_tool *tools;
+ size_t tool_count;
+ mcp_client **clients;
+ size_t client_count;
+} mcp_registry;
+
+/* Test-only loopback transport: queued responses + recorded outgoing lines.
+ * Call mcp_memory_init before use; the caller keeps ownership, the client
+ * borrows it for the lifetime of the connection. */
+typedef struct {
+ strlist responses;
+ strlist sent;
+} mcp_memory;
+
+void mcp_memory_init(mcp_memory *m);
+
+/* Parse a raw mcpServers value (JSON text). Returns 1 and fills *out, or 0
+ * (no error) when it declares neither a command nor a url. */
+int mcp_server_config_from_json(const char *json, mcp_server_config *out);
+void mcp_server_config_free(mcp_server_config *c);
+
+/* Spawn/attach a server. Returns NULL with *err on failure. */
+mcp_client *mcp_connect_stdio(const mcp_server_config *cfg, char **err);
+mcp_client *mcp_connect_http(const mcp_server_config *cfg, char **err);
+/* Test-only: wrap already-open stdio fds (closed on free, no child reaped). */
+mcp_client *mcp_connect_fds(int in_fd, int out_fd, char **err);
+/* Test-only: one buffered line read with carry semantics. */
+char *mcp_read_line(mcp_client *c, const char *method, char **err);
+mcp_client *mcp_connect_memory(mcp_memory *mem, char **err);
+void mcp_client_free(mcp_client *c);
+
+/* Handshake: initialize request + notifications/initialized notification. */
+int mcp_initialize(mcp_client *c, char **err);
+
+/* Discover the server's tools. Caller frees with mcp_tools_free (does not
+ * free the clients). */
+int mcp_list_tools(mcp_client *c, mcp_tool **tools, size_t *n, char **err);
+void mcp_tools_free(mcp_tool *tools, size_t n);
+
+/* Invoke a tool; returns its text content or NULL with *err set. */
+char *mcp_call_tool(mcp_client *c, const char *name, const char *args_json,
+ char **err);
+
+/* Low-level JSON-RPC: returns the `result` value as JSON text (owned), or
+ * NULL with *err ("server error {code}: {message}" on JSON-RPC errors). */
+char *mcp_request(mcp_client *c, const char *method, const char *params_json,
+ char **err);
+int mcp_notify(mcp_client *c, const char *method, const char *params_json,
+ char **err);
+
+/* Extract the JSON-RPC response from an HTTP body (plain or SSE). */
+char *mcp_extract_json(const char *content_type, const char *body, char **err);
+
+/* Connect, handshake, and discover every server, in order. Unreachable
+ * servers become warnings (`mcp \`name\`: reason`) and are skipped. */
+void mcp_connect_all(const char *const *names, const char *const *configs,
+ size_t n, mcp_registry *out, strlist *warnings);
+void mcp_registry_free(mcp_registry *r);
+
+/* Dispatch through the owning client, wrapping failures as
+ * `MCP tool \`name\` failed: ...`. */
+char *mcp_tool_invoke(const mcp_tool *t, const char *args_json, char **err);
+
+#endif
diff --git a/src/message.cpp b/src/message.cpp
new file mode 100644
index 0000000..18e9c3b
--- /dev/null
+++ b/src/message.cpp
@@ -0,0 +1 @@
+#include "message.h"
diff --git a/src/message.h b/src/message.h
new file mode 100644
index 0000000..6ff53f6
--- /dev/null
+++ b/src/message.h
@@ -0,0 +1,367 @@
+#ifndef LLSH_MESSAGE_H
+#define LLSH_MESSAGE_H
+
+#include <nlohmann/json.hpp>
+
+#include <string>
+#include <variant>
+#include <vector>
+
+/* ------------------------------------------------------------------ */
+/* Message model (mirrors the rig message types llsh uses) */
+/* */
+/* message = system_message { text } */
+/* | user_message { parts: [user_part] } */
+/* | assistant_message { parts: [assistant_part] } */
+/* user_part = user_text_part { text } */
+/* | user_tool_result_part { call, name, */
+/* content: [trc] } */
+/* trc = trc_text { text } | trc_json { value } | trc_image */
+/* assistant_part = asst_text_part { text } */
+/* | asst_tool_call_part { name, args } */
+/* | asst_reasoning_part { text } */
+/* | asst_image_part */
+/* */
+/* JSON shape (recursive, nlohmann ADL below): each type serializes */
+/* to exactly this wire format and the `role`/`type` discriminators */
+/* keep old session files loadable. */
+/* ------------------------------------------------------------------ */
+
+/* -- tool result content -- */
+
+struct trc_text {
+ std::string text;
+};
+struct trc_json {
+ std::string value;
+};
+struct trc_image {
+};
+using tool_result_content = std::variant<trc_text, trc_json, trc_image>;
+
+/* -- user parts -- */
+
+struct user_text_part {
+ std::string text;
+};
+struct user_tool_result_part {
+ std::string call; /* tool-call id */
+ std::string name; /* tool name */
+ std::vector<tool_result_content> content;
+};
+using user_part = std::variant<user_text_part, user_tool_result_part>;
+
+/* -- assistant parts -- */
+
+struct asst_text_part {
+ std::string text;
+};
+struct asst_tool_call_part {
+ std::string name;
+ std::string args; /* stringified arguments */
+};
+struct asst_reasoning_part {
+ std::string text;
+};
+struct asst_image_part {
+};
+using assistant_part =
+ std::variant<asst_text_part, asst_tool_call_part, asst_reasoning_part,
+ asst_image_part>;
+
+/* -- messages -- */
+
+struct system_message {
+ std::string text;
+};
+struct user_message {
+ std::vector<user_part> parts;
+};
+struct assistant_message {
+ std::vector<assistant_part> parts;
+};
+
+struct message {
+ std::variant<system_message, user_message, assistant_message> body;
+
+ bool is_system() const {
+ return std::holds_alternative<system_message>(body);
+ }
+ bool is_user() const {
+ return std::holds_alternative<user_message>(body);
+ }
+ bool is_assistant() const {
+ return std::holds_alternative<assistant_message>(body);
+ }
+
+ system_message &system() { return std::get<system_message>(body); }
+ const system_message &system() const {
+ return std::get<system_message>(body);
+ }
+ user_message &user() { return std::get<user_message>(body); }
+ const user_message &user() const {
+ return std::get<user_message>(body);
+ }
+ assistant_message &assistant() {
+ return std::get<assistant_message>(body);
+ }
+ const assistant_message &assistant() const {
+ return std::get<assistant_message>(body);
+ }
+};
+
+/* Constructors (fresh message, content copied). */
+inline message msg_system(const char *text) {
+ system_message m;
+ m.text = text ? text : "";
+ return message{std::move(m)};
+}
+inline message msg_user_text(const char *text) {
+ user_message m;
+ user_text_part p;
+ p.text = text ? text : "";
+ m.parts.push_back(std::move(p));
+ return message{std::move(m)};
+}
+inline message msg_user_tool_result(const char *call, const char *name,
+ const char *text) {
+ user_message m;
+ user_tool_result_part p;
+ p.call = call ? call : "";
+ p.name = name ? name : "";
+ trc_text t;
+ t.text = text ? text : "";
+ p.content.push_back(std::move(t));
+ m.parts.push_back(std::move(p));
+ return message{std::move(m)};
+}
+inline message msg_assistant_text(const char *text) {
+ assistant_message m;
+ asst_text_part p;
+ p.text = text ? text : "";
+ m.parts.push_back(std::move(p));
+ return message{std::move(m)};
+}
+inline message msg_assistant_tool_call(const char *name, const char *args) {
+ assistant_message m;
+ asst_tool_call_part p;
+ p.name = name ? name : "";
+ p.args = args ? args : "";
+ m.parts.push_back(std::move(p));
+ return message{std::move(m)};
+}
+
+/* Append parts to an existing message. */
+inline message &msg_push_user_text(message &m, const char *text) {
+ if (!m.is_user())
+ m.body = user_message{};
+ user_text_part p;
+ p.text = text ? text : "";
+ m.user().parts.push_back(std::move(p));
+ return m;
+}
+inline message &msg_push_user_tool_result(message &m, const char *call,
+ const char *name, const char *text) {
+ if (!m.is_user())
+ m.body = user_message{};
+ user_tool_result_part p;
+ p.call = call ? call : "";
+ p.name = name ? name : "";
+ trc_text t;
+ t.text = text ? text : "";
+ p.content.push_back(std::move(t));
+ m.user().parts.push_back(std::move(p));
+ return m;
+}
+inline message &msg_push_assistant_text(message &m, const char *text) {
+ if (!m.is_assistant())
+ m.body = assistant_message{};
+ asst_text_part p;
+ p.text = text ? text : "";
+ m.assistant().parts.push_back(std::move(p));
+ return m;
+}
+inline message &msg_push_assistant_tool_call(message &m, const char *name,
+ const char *args) {
+ if (!m.is_assistant())
+ m.body = assistant_message{};
+ asst_tool_call_part p;
+ p.name = name ? name : "";
+ p.args = args ? args : "";
+ m.assistant().parts.push_back(std::move(p));
+ return m;
+}
+inline message &msg_push_assistant_reasoning(message &m, const char *text) {
+ if (!m.is_assistant())
+ m.body = assistant_message{};
+ asst_reasoning_part p;
+ p.text = text ? text : "";
+ m.assistant().parts.push_back(std::move(p));
+ return m;
+}
+
+/* ------------------------------------------------------------------ */
+/* History (growable message list) */
+/* ------------------------------------------------------------------ */
+
+struct history {
+ std::vector<message> msgs;
+};
+
+/* Destroy the stored messages and the vector buffer; the `history` object
+ * (wherever it lives) must not be used afterwards. */
+inline void history_free(history *h) { h->msgs.~vector<message>(); }
+inline void history_push(history *h, message m) {
+ h->msgs.push_back(std::move(m));
+}
+/* Remove and return element i. */
+inline message history_take(history *h, size_t i) {
+ message out = std::move(h->msgs[i]);
+ h->msgs.erase(h->msgs.begin() + static_cast<long>(i));
+ return out;
+}
+inline size_t history_len(const history *h) { return h->msgs.size(); }
+inline const message *history_at(const history *h, size_t i) {
+ return &h->msgs[i];
+}
+
+/* ------------------------------------------------------------------ */
+/* Recursive JSON (nlohmann ADL: to_json / from_json) */
+/* ------------------------------------------------------------------ */
+
+/* Tolerant string read: missing / null / non-string values become "". */
+inline std::string jstring(const nlohmann::json &j, const char *key) {
+ if (j.contains(key) && j.at(key).is_string())
+ return j.at(key).get<std::string>();
+ return std::string();
+}
+
+inline void to_json(nlohmann::json &j, const tool_result_content &v) {
+ if (const auto *t = std::get_if<trc_text>(&v))
+ j = nlohmann::json{{"text", t->text}};
+ else if (const auto *s = std::get_if<trc_json>(&v))
+ j = nlohmann::json{{"json", s->value}};
+ else
+ j = nlohmann::json{{"image", "1"}};
+}
+inline void from_json(const nlohmann::json &j, tool_result_content &v) {
+ if (j.contains("text")) {
+ trc_text t;
+ t.text = jstring(j, "text");
+ v = std::move(t);
+ } else if (j.contains("json")) {
+ trc_json s;
+ s.value = jstring(j, "json");
+ v = std::move(s);
+ } else {
+ v = trc_image{};
+ }
+}
+
+inline void to_json(nlohmann::json &j, const user_part &p) {
+ if (const auto *t = std::get_if<user_text_part>(&p))
+ j = nlohmann::json{{"type", "text"}, {"text", t->text}};
+ else {
+ const auto &r = std::get<user_tool_result_part>(p);
+ nlohmann::json arr = nlohmann::json::array();
+ for (const auto &c : r.content)
+ arr.push_back(c);
+ j = nlohmann::json{{"type", "tool_result"},
+ {"call", r.call},
+ {"name", r.name},
+ {"content", std::move(arr)}};
+ }
+}
+inline void from_json(const nlohmann::json &j, user_part &p) {
+ const std::string type = jstring(j, "type");
+ if (type == "text") {
+ user_text_part t;
+ t.text = jstring(j, "text");
+ p = std::move(t);
+ } else if (type == "tool_result") {
+ user_tool_result_part r;
+ r.call = jstring(j, "call");
+ r.name = jstring(j, "name");
+ if (j.contains("content") && j.at("content").is_array())
+ for (const auto &c : j.at("content"))
+ r.content.push_back(c.get<tool_result_content>());
+ p = std::move(r);
+ } else {
+ user_text_part t;
+ t.text = jstring(j, "text");
+ p = std::move(t);
+ }
+}
+
+inline void to_json(nlohmann::json &j, const assistant_part &p) {
+ if (const auto *t = std::get_if<asst_text_part>(&p))
+ j = nlohmann::json{{"type", "text"}, {"text", t->text}};
+ else if (const auto *c = std::get_if<asst_tool_call_part>(&p))
+ j = nlohmann::json{{"type", "tool_call"},
+ {"name", c->name},
+ {"args", c->args}};
+ else if (const auto *r = std::get_if<asst_reasoning_part>(&p))
+ j = nlohmann::json{{"type", "reasoning"}, {"text", r->text}};
+ else
+ j = nlohmann::json::object();
+}
+inline void from_json(const nlohmann::json &j, assistant_part &p) {
+ const std::string type = jstring(j, "type");
+ if (type == "text") {
+ asst_text_part t;
+ t.text = jstring(j, "text");
+ p = std::move(t);
+ } else if (type == "tool_call") {
+ asst_tool_call_part c;
+ c.name = jstring(j, "name");
+ c.args = jstring(j, "args");
+ p = std::move(c);
+ } else if (type == "reasoning") {
+ asst_reasoning_part r;
+ r.text = jstring(j, "text");
+ p = std::move(r);
+ } else {
+ p = asst_image_part{};
+ }
+}
+
+inline void to_json(nlohmann::json &j, const message &m) {
+ if (m.is_system())
+ j = nlohmann::json{{"role", "system"}, {"text", m.system().text}};
+ else if (m.is_user()) {
+ nlohmann::json parts = nlohmann::json::array();
+ for (const auto &p : m.user().parts)
+ parts.push_back(p);
+ j = nlohmann::json{{"role", "user"}, {"parts", std::move(parts)}};
+ } else {
+ nlohmann::json parts = nlohmann::json::array();
+ for (const auto &p : m.assistant().parts)
+ parts.push_back(p);
+ j =
+ nlohmann::json{{"role", "assistant"}, {"parts", std::move(parts)}};
+ }
+}
+inline void from_json(const nlohmann::json &j, message &m) {
+ const std::string role = jstring(j, "role");
+ if (role == "system") {
+ system_message s;
+ s.text = jstring(j, "text");
+ m.body = std::move(s);
+ } else if (role == "assistant") {
+ assistant_message a;
+ if (j.contains("parts") && j.at("parts").is_array())
+ for (const auto &p : j.at("parts"))
+ if (p.is_object())
+ a.parts.push_back(p.get<assistant_part>());
+ m.body = std::move(a);
+ } else {
+ user_message u;
+ if (j.contains("parts") && j.at("parts").is_array())
+ for (const auto &p : j.at("parts"))
+ if (p.is_object())
+ u.parts.push_back(p.get<user_part>());
+ m.body = std::move(u);
+ }
+}
+
+#endif
diff --git a/src/provider.cpp b/src/provider.cpp
new file mode 100644
index 0000000..8ac95db
--- /dev/null
+++ b/src/provider.cpp
@@ -0,0 +1,941 @@
+/* provider.c — the session-bound provider layer for llshc.
+ *
+ * Implements the `llsh_model` vtable on top of llm.h's protocol layer:
+ * wires the session (config, preamble, allowed tools, .agents snapshot),
+ * the built-in + MCP tool registry, and the session emit callback into the
+ * one-shot `complete` and the streaming agent `stream`. */
+#include "provider.h"
+
+#include <atomic>
+#include <cmath>
+#include <limits>
+#include <stdlib.h>
+#include <string.h>
+
+#include "compact.h"
+#include "config.h"
+#include "dotagents.h"
+#include "goal.h"
+#include "message.h"
+#include "limits.h"
+#include <cjson/cJSON.h>
+#include "tools.h"
+#include "util.h"
+
+#define PCAST(m) ((provider_model *)(m))
+#define jstr(o, k) ({ const cJSON *_v = (o) ? cJSON_GetObjectItem((o), (k)) : NULL; \
+ (cJSON_IsString(_v) && _v->valuestring) ? _v->valuestring : ""; })
+
+typedef struct provider_model {
+ llsh_model base; /* must be first */
+ session *sess; /* non-owning back-pointer */
+ mcp_registry *mcp;
+ provider_role role;
+ /* Cached `"tools":[...]` request fragment. Valid while (provider,
+ * allowed list, mcp tools) is unchanged; invalidated on key mismatch. */
+ char *tools_json;
+ unsigned long tools_key;
+ CURL *curl; /* persistent easy handle, created on first post */
+} provider_model;
+
+/* ------------------------------------------------------------------ */
+/* Session -> llm.h adapters */
+/* ------------------------------------------------------------------ */
+
+static llm_conn conn_of(const session *s) {
+ const config *cfg = session_active_config((session *)s);
+ llm_conn c;
+ switch (cfg->provider) {
+ case PROV_ANTHROPIC:
+ c.provider = LLM_ANTHROPIC;
+ break;
+ case PROV_OLLAMA:
+ c.provider = LLM_OLLAMA;
+ break;
+ default:
+ c.provider = LLM_OPENAI;
+ break;
+ }
+ c.model = cfg->model;
+ c.base_url = cfg->base_url;
+ c.api_key = cfg->api_key;
+ c.ca_bundle = llsh_ca_bundle();
+ return c;
+}
+
+/* Join the tool-result content parts of a user_tool_result_part. Caller
+ * frees. */
+static char *trc_join(const user_tool_result_part &r) {
+ xbuf b;
+ xbuf_init(&b);
+ bool first = true;
+ for (const auto &c : r.content) {
+ if (!first)
+ xbuf_push(&b, '\n');
+ if (const auto *t = std::get_if<trc_text>(&c))
+ xbuf_put(&b, t->text.c_str());
+ else if (const auto *s = std::get_if<trc_json>(&c))
+ xbuf_put(&b, s->value.c_str());
+ first = false;
+ }
+ char *s = xbuf_steal(&b);
+ xbuf_free(&b);
+ return s;
+}
+
+/* App messages -> borrowed llm_message array (tool-result parts joined). */
+typedef struct {
+ llm_message *msgs;
+ size_t n;
+ char **joined; /* owned joined trc strings */
+ size_t joined_n;
+} app_llm_msgs;
+
+static app_llm_msgs app_to_llm(const message *const *msgs, size_t n) {
+ app_llm_msgs a;
+ memset(&a, 0, sizeof a);
+ a.msgs = (llm_message *)xmalloc(n ? n * sizeof *a.msgs : 1);
+ a.n = n;
+ for (size_t i = 0; i < n; i++) {
+ const message *m = msgs[i];
+ llm_message *o = &a.msgs[i];
+ size_t cnt = 0;
+ if (m->is_system()) {
+ o->role = LLM_ROLE_SYSTEM;
+ cnt = 1;
+ } else if (m->is_user()) {
+ o->role = LLM_ROLE_USER;
+ cnt = m->user().parts.size();
+ } else {
+ o->role = LLM_ROLE_ASSISTANT;
+ cnt = m->assistant().parts.size();
+ }
+ if (cnt == 0)
+ continue;
+ o->parts = (llm_part *)xmalloc(cnt * sizeof *o->parts);
+ size_t k = 0;
+ if (m->is_system()) {
+ o->parts[k].kind = LLM_TEXT;
+ o->parts[k].text = m->system().text.c_str();
+ k++;
+ } else if (m->is_user()) {
+ for (size_t j = 0; j < m->user().parts.size(); j++) {
+ const auto &p = m->user().parts[j];
+ if (const auto *t = std::get_if<user_text_part>(&p)) {
+ o->parts[k].kind = LLM_TEXT;
+ o->parts[k].text = t->text.c_str();
+ } else {
+ const auto &r = std::get<user_tool_result_part>(p);
+ char *joined = trc_join(r);
+ a.joined =
+ (char **)xrealloc(a.joined,
+ (a.joined_n + 1) * sizeof *a.joined);
+ a.joined[a.joined_n++] = joined;
+ o->parts[k].kind = LLM_TOOL_RESULT;
+ o->parts[k].text = joined;
+ o->parts[k].name = r.name.c_str();
+ o->parts[k].call = r.call.c_str();
+ }
+ k++;
+ }
+ } else {
+ for (size_t j = 0; j < m->assistant().parts.size(); j++) {
+ const auto &p = m->assistant().parts[j];
+ if (const auto *t = std::get_if<asst_text_part>(&p)) {
+ o->parts[k].kind = LLM_TEXT;
+ o->parts[k].text = t->text.c_str();
+ } else if (const auto *r = std::get_if<asst_reasoning_part>(&p)) {
+ o->parts[k].kind = LLM_REASONING;
+ o->parts[k].text = r->text.c_str();
+ } else if (const auto *c = std::get_if<asst_tool_call_part>(&p)) {
+ o->parts[k].kind = LLM_TOOL_CALL;
+ o->parts[k].name = c->name.c_str();
+ o->parts[k].args = c->args.c_str();
+ }
+ k++;
+ }
+ }
+ o->part_count = k;
+ }
+ return a;
+}
+
+static void app_llm_msgs_free(app_llm_msgs *a) {
+ for (size_t i = 0; i < a->n; i++)
+ free(a->msgs[i].parts);
+ free(a->msgs);
+ for (size_t i = 0; i < a->joined_n; i++)
+ free(a->joined[i]);
+ free(a->joined);
+ memset(a, 0, sizeof *a);
+}
+
+/* Bridge the session's emit callback onto llm.h's primitive emitter. */
+typedef struct {
+ sess_emit_cb cb;
+ void *user;
+} emit_user;
+
+static int emit_bridge(llm_provider prov, llm_emit_kind kind,
+ const char *delta, void *ud) {
+ (void)prov;
+ emit_user *eu = (emit_user *) ud;
+ sess_emit ev;
+ memset(&ev, 0, sizeof ev);
+ ev.kind = (kind == LLM_EMIT_TEXT) ? SESS_EMIT_TEXT : SESS_EMIT_REASONING;
+ ev.text = xstrdup(delta);
+ int rc = eu->cb ? eu->cb(&ev, eu->user) : 0;
+ sess_emit_free(&ev); /* creator owns the payload; cb only reads it */
+ return rc;
+}
+
+/* ------------------------------------------------------------------ */
+/* Tool execution */
+/* ------------------------------------------------------------------ */
+
+static bool tool_allowed(const char *name, const strlist *allowed);
+
+static const char *required_string(const cJSON *args, const char *key) {
+ const cJSON *v = args ? cJSON_GetObjectItemCaseSensitive(args, key) : NULL;
+ return cJSON_IsString(v) && v->valuestring ? v->valuestring : NULL;
+}
+
+static char *bad_tool_arg(const char *tool, const char *arg, char **err) {
+ if (err)
+ *err = xstrfmt("%s requires string `%s`", tool, arg);
+ return NULL;
+}
+
+static char *exec_builtin(const session *s, const char *name,
+ const char *args_json, unsigned long shell_timeout,
+ char **err) {
+ const dotagents *dot = s ? session_dot(s) : NULL;
+ const config *cfg = s ? session_config(s) : NULL;
+ if (err)
+ *err = NULL;
+ cJSON *a = cJSON_Parse(args_json ? args_json : "{}");
+ if (!cJSON_IsObject(a)) {
+ cJSON_Delete(a);
+ if (err)
+ *err = xstrdup("tool arguments must be a JSON object");
+ return NULL;
+ }
+ char *out = NULL;
+ if (xstreq(name, "read_file")) {
+ if (!required_string(a, "path")) {
+ out = bad_tool_arg(name, "path", err);
+ goto done;
+ }
+ unsigned long off = 0, lim = 0;
+ const cJSON *ov = cJSON_GetObjectItem(a, "offset");
+ const cJSON *lv = cJSON_GetObjectItem(a, "limit");
+ if (ov && (!cJSON_IsNumber(ov) || !std::isfinite(ov->valuedouble) ||
+ ov->valuedouble < 1 || std::floor(ov->valuedouble) != ov->valuedouble ||
+ ov->valuedouble >= (double)std::numeric_limits<unsigned long>::max())) {
+ out = bad_tool_arg(name, "offset (positive integer)", err);
+ goto done;
+ }
+ if (ov)
+ off = (unsigned long)ov->valuedouble;
+ if (lv && (!cJSON_IsNumber(lv) || !std::isfinite(lv->valuedouble) ||
+ lv->valuedouble < 1 || std::floor(lv->valuedouble) != lv->valuedouble ||
+ lv->valuedouble >= (double)std::numeric_limits<unsigned long>::max())) {
+ out = bad_tool_arg(name, "limit (positive integer)", err);
+ goto done;
+ }
+ if (lv)
+ lim = (unsigned long)lv->valuedouble;
+ out = tool_read_file(jstr(a, "path"), off, lim, err);
+ } else if (xstreq(name, "write_file")) {
+ if (!required_string(a, "path") || !required_string(a, "content")) {
+ out = bad_tool_arg(name,
+ required_string(a, "path") ? "content" : "path",
+ err);
+ goto done;
+ }
+ out = tool_write_file(required_string(a, "path"),
+ required_string(a, "content"), err);
+ } else if (xstreq(name, "run_shell")) {
+ const char *command = required_string(a, "command");
+ if (!command || !command[0]) {
+ out = bad_tool_arg(name, "command", err);
+ goto done;
+ }
+ const tool_limits *lim = tool_limits_current();
+ if (shell_gate(command, lim, err) == 0)
+ out = tool_run_shell_timed(command, shell_timeout, err);
+ } else if (xstreq(name, "use_skill")) {
+ if (!required_string(a, "id")) {
+ out = bad_tool_arg(name, "id", err);
+ goto done;
+ }
+ out = tool_use_skill(dot, required_string(a, "id"), err);
+ } else if (xstreq(name, "search_memories")) {
+ if (!required_string(a, "query")) {
+ out = bad_tool_arg(name, "query", err);
+ goto done;
+ }
+ out = tool_search_memories(dot, required_string(a, "query"), err);
+ } else if (xstreq(name, "embed")) {
+ if (!required_string(a, "text")) {
+ out = bad_tool_arg(name, "text", err);
+ goto done;
+ }
+ out = tool_embed(required_string(a, "text"), cfg, dot, err);
+ } else if (xstreq(name, "llm")) {
+ llm_tool_call call;
+ memset(&call, 0, sizeof call);
+ call.temperature = -1.0;
+ call.prompt = required_string(a, "prompt");
+ call.model = jstr(a, "model");
+ const cJSON *tv = cJSON_GetObjectItem(a, "temperature");
+ if (tv && (!cJSON_IsNumber(tv) || !std::isfinite(tv->valuedouble) ||
+ tv->valuedouble < 0 || tv->valuedouble > 2)) {
+ out = bad_tool_arg(name, "temperature (0..2)", err);
+ goto done;
+ }
+ if (tv)
+ call.temperature = tv->valuedouble;
+ const cJSON *mv = cJSON_GetObjectItem(a, "max_tokens");
+ if (mv && (!cJSON_IsNumber(mv) || !std::isfinite(mv->valuedouble) ||
+ mv->valuedouble < 1 || std::floor(mv->valuedouble) != mv->valuedouble ||
+ mv->valuedouble >= (double)std::numeric_limits<unsigned long>::max())) {
+ out = bad_tool_arg(name, "max_tokens (positive integer)", err);
+ goto done;
+ }
+ if (mv)
+ call.max_tokens = (unsigned long)mv->valuedouble;
+ call.reasoning_effort = jstr(a, "reasoning_effort");
+ const cJSON *bv = cJSON_GetObjectItem(a, "thinking_budget");
+ if (bv && (!cJSON_IsNumber(bv) || !std::isfinite(bv->valuedouble) ||
+ bv->valuedouble < 1 || std::floor(bv->valuedouble) != bv->valuedouble ||
+ bv->valuedouble >= (double)std::numeric_limits<unsigned long>::max())) {
+ out = bad_tool_arg(name, "thinking_budget (positive integer)", err);
+ goto done;
+ }
+ if (bv)
+ call.thinking_budget = (unsigned long)bv->valuedouble;
+ const cJSON *sv = cJSON_GetObjectItem(a, "response_schema");
+ char *schema_ser = NULL;
+ if (sv) {
+ if (cJSON_IsString(sv) && sv->valuestring)
+ call.response_schema = sv->valuestring;
+ else {
+ schema_ser = cJSON_PrintUnformatted(sv);
+ call.response_schema = schema_ser;
+ }
+ }
+ const cJSON *ev = cJSON_GetObjectItem(a, "embed");
+ if (ev && cJSON_IsBool(ev))
+ call.embed = cJSON_IsTrue(ev);
+ if (!cfg) {
+ if (err)
+ *err = xstrdup("llm: no session config");
+ } else {
+ out = tool_llm(&call, cfg, dot, err);
+ }
+ free(schema_ser);
+ } else if (xstreq(name, "compact")) {
+ compact_report rep;
+ memset(&rep, 0, sizeof rep);
+ int rc = session_force_compact((session *)s, &rep, err);
+ if (rc > 0) {
+ session_print_compact(&rep);
+ out = xstrfmt("compacted: %lu -> %lu tokens", rep.tokens_before,
+ rep.tokens_after);
+ } else if (rc == 0) {
+ out = xstrdup("history too short to compact");
+ }
+ } else {
+ if (err)
+ *err = xstrfmt("unknown tool `%s`", name);
+ }
+done:
+ cJSON_Delete(a);
+ return out;
+}
+
+static char *exec_tool(const provider_model *p, const char *name,
+ const char *args_json, unsigned long shell_timeout,
+ char **err) {
+ const strlist *allowed = session_allowed_tools(p->sess);
+ if (!tool_allowed(name, allowed)) {
+ if (err)
+ *err = xstrfmt("tool `%s` is not allowed by the active profile", name);
+ return NULL;
+ }
+ if (p->mcp) {
+ for (size_t i = 0; i < p->mcp->tool_count; i++)
+ if (xstreq(p->mcp->tools[i].qualified_name, name))
+ return mcp_tool_invoke(&p->mcp->tools[i], args_json, err);
+ }
+ return exec_builtin(p->sess, name, args_json, shell_timeout, err);
+}
+
+/* ------------------------------------------------------------------ */
+/* Tool schemas (registry -> `"tools":[...]` fragment) */
+/* ------------------------------------------------------------------ */
+
+static bool tool_allowed(const char *name, const strlist *allowed) {
+ if (!allowed || allowed->len == 0)
+ return true;
+ for (size_t i = 0; i < allowed->len; i++)
+ if (xstreq(allowed->items[i], name))
+ return true;
+ return false;
+}
+
+/* FNV-1a over (provider, allowed tools, mcp tool names) -- the cache key
+ * for the tool-schema fragment. */
+static unsigned long tfv(unsigned long h, const char *s) {
+ for (const char *c = s; *c; c++)
+ h = (h ^ (unsigned char)*c) * 1099511628211UL;
+ return (h ^ 0xff) * 1099511628211UL;
+}
+
+static unsigned long tools_fingerprint(const config *cfg,
+ const strlist *allowed,
+ mcp_registry *mcp) {
+ unsigned long h = 1469598103934665603UL;
+ h = (h ^ (unsigned long)cfg->provider) * 1099511628211UL;
+ if (allowed)
+ for (size_t i = 0; i < allowed->len; i++)
+ h = tfv(h, allowed->items[i]);
+ if (mcp)
+ for (size_t i = 0; i < mcp->tool_count; i++)
+ h = tfv(h, mcp->tools[i].qualified_name);
+ return h;
+}
+
+/* The provider-shaped `"tools":[...]` fragment, cached on the model.
+ * Rebuilt only when the fingerprint (provider, allowed list, mcp tools)
+ * changes. Borrowed from `p`; valid until provider_destroy. */
+static const char *tools_fragment(provider_model *p, const config *cfg,
+ const strlist *allowed) {
+ unsigned long key = tools_fingerprint(cfg, allowed, p->mcp);
+ if (p->tools_json && p->tools_key == key)
+ return p->tools_json;
+ free(p->tools_json);
+ p->tools_json = NULL;
+ p->tools_key = key;
+
+ xbuf frag;
+ xbuf_init(&frag);
+ xbuf_put(&frag, "\"tools\":[");
+ bool first = true;
+ for (size_t i = 0; i < tool_builtin_count(); i++) {
+ const char *name = tool_builtin_name(i);
+ if (!tool_allowed(name, allowed))
+ continue;
+ cJSON *t;
+ if (cfg->provider == PROV_ANTHROPIC) {
+ t = cJSON_CreateObject();
+ cJSON_AddStringToObject(t, "name", name);
+ cJSON_AddStringToObject(t, "description", tool_desc(name));
+ cJSON_AddItemToObject(t, "input_schema", tool_params_json(name));
+ } else {
+ t = cJSON_CreateObject();
+ cJSON_AddStringToObject(t, "type", "function");
+ cJSON *f = cJSON_AddObjectToObject(t, "function");
+ cJSON_AddStringToObject(f, "name", name);
+ cJSON_AddStringToObject(f, "description", tool_desc(name));
+ cJSON_AddItemToObject(f, "parameters", tool_params_json(name));
+ }
+ char *ts = cJSON_PrintUnformatted(t);
+ cJSON_Delete(t);
+ if (!first)
+ xbuf_push(&frag, ',');
+ xbuf_put(&frag, ts ? ts : "{}");
+ free(ts);
+ first = false;
+ }
+ if (p->mcp) {
+ for (size_t i = 0; i < p->mcp->tool_count; i++) {
+ const mcp_tool *mt = &p->mcp->tools[i];
+ if (!tool_allowed(mt->qualified_name, allowed))
+ continue;
+ cJSON *t = cJSON_CreateObject();
+ cJSON *schema = mt->input_schema ? cJSON_Parse(mt->input_schema)
+ : NULL;
+ if (!schema) {
+ schema = cJSON_CreateObject();
+ cJSON_AddStringToObject(schema, "type", "object");
+ }
+ if (cfg->provider == PROV_ANTHROPIC) {
+ cJSON_AddStringToObject(t, "name", mt->qualified_name);
+ if (mt->description)
+ cJSON_AddStringToObject(t, "description", mt->description);
+ cJSON_AddItemToObject(t, "input_schema", schema);
+ } else {
+ cJSON_AddStringToObject(t, "type", "function");
+ cJSON *fn = cJSON_AddObjectToObject(t, "function");
+ cJSON_AddStringToObject(fn, "name", mt->qualified_name);
+ if (mt->description)
+ cJSON_AddStringToObject(fn, "description", mt->description);
+ cJSON_AddItemToObject(fn, "parameters", schema);
+ }
+ char *ts = cJSON_PrintUnformatted(t);
+ cJSON_Delete(t);
+ if (!first)
+ xbuf_push(&frag, ',');
+ xbuf_put(&frag, ts ? ts : "{}");
+ free(ts);
+ first = false;
+ }
+ }
+ xbuf_push(&frag, ']');
+ p->tools_json = xbuf_steal(&frag);
+ xbuf_free(&frag);
+ return p->tools_json;
+}
+
+/* ------------------------------------------------------------------ */
+/* complete (summarizer / evaluator) */
+/* ------------------------------------------------------------------ */
+
+static char *provider_complete(const llsh_model *mm, const char *prompt,
+ sess_error *err) {
+ provider_model *p = PCAST(mm);
+ if (err)
+ *err = (sess_error){0};
+ if (!p->sess) {
+ if (err)
+ err->message = xstrdup("provider model not attached to a session");
+ return NULL;
+ }
+ const char *preamble;
+ if (p->role == PROV_ROLE_SUMMARIZER)
+ preamble = compact_summarizer_preamble();
+ else if (p->role == PROV_ROLE_EVALUATOR)
+ preamble = goal_evaluator_preamble();
+ else if (p->role == PROV_ROLE_RUBRIC)
+ preamble = compact_rubric_preamble();
+ else
+ preamble = session_current_preamble(p->sess);
+
+ /* single user message carrying the prompt */
+ llm_part part;
+ memset(&part, 0, sizeof part);
+ part.kind = LLM_TEXT;
+ part.text = prompt ? prompt : "";
+ llm_message one;
+ one.role = LLM_ROLE_USER;
+ one.parts = &part;
+ one.part_count = 1;
+
+ llm_conn conn = conn_of(p->sess);
+ char *req = llm_build_request(&conn, preamble, &one, 1, NULL, false);
+ char *url = llm_url(&conn);
+ long status = 0;
+ char *body = NULL, *headers = NULL, *herr = NULL;
+ int hrc =
+ llm_post(&conn, url, req, &status, &body, &headers, &herr, NULL,
+ &p->curl);
+ free(req);
+ free(url);
+
+ if (hrc != 0) {
+ if (err) {
+ err->message = herr ? herr : xstrdup("transport error");
+ err->http_status = 0;
+ err->transport = 1;
+ }
+ free(body);
+ free(headers);
+ return NULL;
+ }
+ char *result = NULL;
+ llm_completion c;
+ memset(&c, 0, sizeof c);
+ if (llm_parse_body(&conn, body, &c) == 0) {
+ if (c.text && c.text[0])
+ result = c.text;
+ else
+ result = xstrdup("");
+ c.text = NULL;
+ llm_completion_free(&c);
+ }
+ if (status < 200 || status >= 300 || !result) {
+ char *em = llm_parse_error(body ? body : "", status);
+ if (err) {
+ err->message = em;
+ err->http_status = (int)status;
+ err->transport = 0;
+ err->retry_after_secs = llm_header_retry_after(headers);
+ }
+ free(result);
+ result = NULL;
+ }
+ free(body);
+ free(headers);
+ return result;
+}
+
+/* ------------------------------------------------------------------ */
+/* stream (agent turn) */
+/* ------------------------------------------------------------------ */
+
+void provider_trim_dangling_calls(message *msgs, size_t n) {
+ for (size_t i = 0; i < n; i++) {
+ message *m = &msgs[i];
+ if (!m->is_assistant() || m->assistant().parts.empty())
+ continue;
+ size_t calls = 0;
+ for (const auto &p : m->assistant().parts)
+ if (std::holds_alternative<asst_tool_call_part>(p))
+ calls++;
+ if (calls == 0)
+ continue;
+ size_t results = 0;
+ for (size_t k = i + 1; k < n; k++) {
+ message *u = &msgs[k];
+ if (u->is_user() && !u->user().parts.empty() &&
+ std::holds_alternative<user_tool_result_part>(
+ u->user().parts[0]))
+ results++;
+ else
+ break;
+ }
+ if (results >= calls)
+ continue;
+ size_t kept = 0;
+ auto &parts = m->assistant().parts;
+ for (size_t j = 0; j < parts.size(); j++) {
+ if (std::holds_alternative<asst_tool_call_part>(parts[j])) {
+ if (kept >= results) {
+ parts.erase(parts.begin() + static_cast<long>(j));
+ j--;
+ } else {
+ kept++;
+ }
+ }
+ }
+ }
+}
+
+static int provider_stream(const llsh_model *mm,
+ const message *history, size_t nhistory,
+ const char *prompt, sess_emit_cb cb, void *user,
+ std::atomic<bool> *stop, char **assistant_text,
+ unsigned long *input_tokens, message **new_messages,
+ size_t *n_new, sess_error *err) {
+ provider_model *p = PCAST(mm);
+ if (assistant_text)
+ *assistant_text = NULL;
+ if (input_tokens)
+ *input_tokens = 0;
+ if (new_messages)
+ *new_messages = NULL;
+ if (n_new)
+ *n_new = 0;
+ if (err)
+ *err = (sess_error){0};
+
+ if (!p->sess) {
+ if (err)
+ err->message = xstrdup("provider model not attached to a session");
+ return SESS_STREAM_FAILED;
+ }
+
+ const config *cfg = session_active_config(p->sess);
+ const char *preamble = session_current_preamble(p->sess);
+ const strlist *allowed = session_allowed_tools(p->sess);
+ const unsigned long max_turns = cfg->max_turns; /* 0 = unbounded */
+ llm_conn conn = conn_of(p->sess);
+ emit_user eu = {cb, user};
+
+ /* local working messages for this turn (assistant tool calls + results) */
+ std::vector<message> local;
+ int rc = SESS_STREAM_OK;
+ unsigned long last_in = 0;
+ char *final_text = NULL;
+ char *final_reasoning = NULL;
+ size_t call_counter = 0;
+ bool stopped = false;
+
+ for (unsigned long turn = 0; max_turns == 0 || turn < max_turns; turn++) {
+ if (stop && stop->load()) {
+ stopped = true;
+ break;
+ }
+ /* combined = history + [prompt] + local */
+ message pm = msg_user_text(prompt ? prompt : "");
+ size_t total = nhistory + 1 + local.size();
+ const message **all = (const message **)xmalloc(total * sizeof *all);
+ for (size_t i = 0; i < nhistory; i++)
+ all[i] = &history[i];
+ all[nhistory] = &pm;
+ for (size_t i = 0; i < local.size(); i++)
+ all[nhistory + 1 + i] = &local[i];
+
+ app_llm_msgs am = app_to_llm(all, total);
+ free(all);
+ const char *tools = tools_fragment(p, cfg, allowed);
+ char *req =
+ llm_build_request(&conn, preamble, am.msgs, am.n, tools, true);
+ app_llm_msgs_free(&am);
+ char *url = llm_url(&conn);
+ long status = 0;
+ char *body = NULL, *headers = NULL, *herr = NULL;
+ bool stopped_post = false;
+ llm_sse_acc acc;
+ llm_sse_acc_init(&acc, conn.provider);
+ int hrc = llm_post_stream(&conn, url, req, &status, &body, &headers,
+ &acc, emit_bridge, &eu, stop, &stopped_post,
+ &herr, &p->curl);
+ free(req);
+ free(url);
+
+ if (hrc != 0) {
+ /* A mid-stream transport drop is retryable and the partial
+ * response is discarded -- but the user has already seen the
+ * streamed text, so a retry re-shows it. Accepted (AUDIT). */
+ if (err) {
+ err->message = herr ? herr : xstrdup("transport error");
+ err->http_status = 0;
+ err->transport = 1;
+ }
+ free(body);
+ free(headers);
+ llm_sse_acc_free(&acc);
+ if (stopped_post)
+ stopped = true; /* user abort: not a retryable failure */
+ else
+ rc = SESS_STREAM_RETRYABLE;
+ break;
+ }
+ if (status < 200 || status >= 300) {
+ char *em = llm_parse_error(body ? body : "", status);
+ bool transient = sess_is_transient_status(status);
+ rc = transient ? SESS_STREAM_RETRYABLE : SESS_STREAM_FAILED;
+ if (err) {
+ err->message = em;
+ err->http_status = (int)status;
+ err->transport = 0;
+ err->retry_after_secs = llm_header_retry_after(headers);
+ }
+ free(body);
+ free(headers);
+ llm_sse_acc_free(&acc);
+ break;
+ }
+
+ llm_completion c;
+ memset(&c, 0, sizeof c);
+ llm_sse_acc_into(&acc, &c);
+ bool live = llm_sse_acc_had_content(&acc);
+ /* Fallback for endpoints that ignore `stream:true` and answer with
+ * one complete JSON body: parse it the non-streaming way; the text
+ * was not emitted live, so it is emitted below. */
+ if (!live && body && body[0] == '{')
+ llm_parse_body(&conn, body, &c);
+ free(body);
+ free(headers);
+ llm_sse_acc_free(&acc);
+
+ if (c.input_tokens)
+ last_in = c.input_tokens;
+
+ /* emit reasoning (chain of thought), if the model returned one and
+ * it did not already stream live */
+ if (!live && c.reasoning && c.reasoning[0]) {
+ sess_emit ev;
+ memset(&ev, 0, sizeof ev);
+ if (sess_assistant_emit("reasoning", c.reasoning, NULL, &ev) == 0) {
+ if (cb && cb(&ev, user)) {
+ sess_emit_free(&ev);
+ stopped = true;
+ llm_completion_free(&c);
+ break;
+ }
+ sess_emit_free(&ev);
+ }
+ }
+
+ /* emit text, unless it already streamed live */
+ if (!live && c.text && c.text[0]) {
+ sess_emit ev;
+ memset(&ev, 0, sizeof ev);
+ if (sess_assistant_emit("text", c.text, NULL, &ev) == 0) {
+ if (cb && cb(&ev, user)) {
+ sess_emit_free(&ev);
+ stopped = true;
+ llm_completion_free(&c);
+ break;
+ }
+ sess_emit_free(&ev);
+ }
+ }
+
+ if (c.tc_len == 0) {
+ /* final answer: take ownership of c.text and reasoning */
+ final_text = c.text ? c.text : xstrdup("");
+ c.text = NULL;
+ final_reasoning = c.reasoning ? c.reasoning : NULL;
+ c.reasoning = NULL;
+ llm_completion_free(&c);
+ break;
+ }
+
+ /* tool calls: build the assistant message + execute + tool results */
+ message amsg;
+ if (c.reasoning && c.reasoning[0])
+ msg_push_assistant_reasoning(amsg, c.reasoning);
+ if (c.text && c.text[0])
+ msg_push_assistant_text(amsg, c.text);
+ for (size_t t = 0; t < c.tc_len; t++)
+ msg_push_assistant_tool_call(amsg, c.tc_name[t], c.tc_args[t]);
+ local.push_back(std::move(amsg));
+
+ for (size_t t = 0; t < c.tc_len; t++) {
+ if (stop && stop->load()) {
+ stopped = true;
+ llm_completion_free(&c);
+ goto end_loop;
+ }
+ /* emit the tool call */
+ sess_emit ev;
+ memset(&ev, 0, sizeof ev);
+ if (sess_assistant_emit("tool_call", c.tc_name[t], c.tc_args[t],
+ &ev) == 0) {
+ if (cb && cb(&ev, user)) {
+ sess_emit_free(&ev);
+ stopped = true;
+ llm_completion_free(&c);
+ goto end_loop;
+ }
+ sess_emit_free(&ev);
+ }
+ /* execute */
+ char *tresult = NULL, *terr = NULL;
+ tresult = exec_tool(p, c.tc_name[t], c.tc_args[t],
+ cfg->shell_timeout_secs, &terr);
+ if (!tresult) {
+ tresult = terr ? terr : xstrdup("tool failed");
+ } else if (terr) {
+ free(terr); /* exec_tool: result and error are exclusive */
+ }
+ char *bounded = tool_truncate_output(tresult);
+ free(tresult);
+ tresult = bounded;
+ /* emit the tool result */
+ {
+ sess_emit ev2;
+ memset(&ev2, 0, sizeof ev2);
+ if (sess_tool_result_emit(c.tc_name[t], tresult, &ev2) == 0) {
+ if (cb && cb(&ev2, user)) {
+ sess_emit_free(&ev2);
+ stopped = true;
+ llm_completion_free(&c);
+ goto end_loop;
+ }
+ sess_emit_free(&ev2);
+ }
+ }
+ /* tool result message */
+ call_counter++;
+ char *id = c.tc_id[t] ? xstrdup(c.tc_id[t])
+ : xstrfmt("call-%d", (int)call_counter);
+ local.push_back(msg_user_tool_result(id, c.tc_name[t], tresult));
+ free(id);
+ free(tresult);
+ }
+ /* One tool round completed: count it for the rubric gates. */
+ if (p->sess)
+ session_note_tool_round(p->sess);
+ llm_completion_free(&c);
+ }
+end_loop:
+ if (input_tokens)
+ *input_tokens = last_in;
+
+ if (stopped && !local.empty())
+ provider_trim_dangling_calls(local.data(), local.size());
+
+ /* build new_messages = [user_text(prompt)] + local */
+ {
+ std::vector<message> nm;
+ nm.push_back(msg_user_text(prompt ? prompt : ""));
+ for (size_t i = 0; i < local.size(); i++)
+ nm.push_back(local[i]);
+ if ((final_text && final_text[0]) ||
+ (final_reasoning && final_reasoning[0])) {
+ message fm;
+ if (final_reasoning && final_reasoning[0])
+ msg_push_assistant_reasoning(fm, final_reasoning);
+ if (final_text && final_text[0])
+ msg_push_assistant_text(fm, final_text);
+ nm.push_back(std::move(fm));
+ }
+ if (new_messages) {
+ *new_messages = new message[nm.size()];
+ for (size_t i = 0; i < nm.size(); i++)
+ (*new_messages)[i] = std::move(nm[i]);
+ }
+ if (n_new)
+ *n_new = nm.size();
+ }
+ if (assistant_text)
+ *assistant_text = xstrdup(final_text ? final_text : "");
+
+ free(final_text);
+ free(final_reasoning);
+
+ if (stopped)
+ rc = SESS_STREAM_STOPPED;
+
+ return rc;
+}
+
+/* ------------------------------------------------------------------ */
+/* Lifecycle */
+/* ------------------------------------------------------------------ */
+
+static void provider_destroy(llsh_model *base) {
+ provider_model *p = PCAST(base);
+ free(p->tools_json);
+ if (p->curl)
+ curl_easy_cleanup(p->curl);
+ free(p);
+}
+
+llsh_model *provider_model_new(session *sess, mcp_registry *mcp,
+ provider_role role) {
+ provider_model *p = (provider_model *) xmalloc(sizeof *p);
+ memset(p, 0, sizeof *p);
+ p->sess = sess;
+ p->mcp = mcp;
+ p->role = role;
+ p->base.complete = provider_complete;
+ p->base.stream = provider_stream;
+ p->base.destroy = provider_destroy;
+ return &p->base;
+}
+
+void provider_model_attach(llsh_model *m, session *sess) {
+ PCAST(m)->sess = sess;
+}
+
+char *provider_preview_request(session *sess, const char *prompt,
+ bool with_tools) {
+ llsh_model *mm = provider_model_new(sess, NULL, PROV_ROLE_MAIN);
+ provider_model *p = PCAST(mm);
+ const config *cfg = session_active_config(sess);
+ const char *preamble = session_current_preamble(sess);
+ const strlist *allowed = session_allowed_tools(sess);
+
+ llm_part part;
+ memset(&part, 0, sizeof part);
+ part.kind = LLM_TEXT;
+ part.text = prompt ? prompt : "";
+ llm_message one;
+ one.role = LLM_ROLE_USER;
+ one.parts = &part;
+ one.part_count = 1;
+
+ llm_conn conn = conn_of(sess);
+ const char *tools = with_tools ? tools_fragment(p, cfg, allowed) : NULL;
+ char *req = llm_build_request(&conn, preamble, &one, 1, tools, false);
+ llsh_model_free(mm);
+ return req;
+}
diff --git a/src/provider.h b/src/provider.h
new file mode 100644
index 0000000..ae60896
--- /dev/null
+++ b/src/provider.h
@@ -0,0 +1,41 @@
+#ifndef LLSH_PROVIDER_H
+#define LLSH_PROVIDER_H
+
+#include <stdbool.h>
+
+#include "llm.h"
+#include "mcp.h"
+#include "session.h"
+
+/* The session-bound provider layer: implements the `llsh_model` vtable on
+ * top of llm.h's protocol layer, wiring in the session (config, preamble,
+ * allowed tools, .agents), the built-in + MCP tool registry, and the
+ * session's emit callback. */
+
+/* Which one-shot role this model serves. `complete` uses the matching
+ * preamble; `stream` (the agent turn) always uses the session preamble. */
+typedef enum {
+ PROV_ROLE_MAIN = 0,
+ PROV_ROLE_SUMMARIZER,
+ PROV_ROLE_EVALUATOR,
+ PROV_ROLE_RUBRIC,
+} provider_role;
+
+/* Create a session-bound model. `sess` may be NULL until
+ * provider_model_attach; `mcp` may be NULL (no MCP tools). */
+llsh_model *provider_model_new(session *sess, mcp_registry *mcp,
+ provider_role role);
+void provider_model_attach(llsh_model *m, session *sess);
+
+/* Test hook: build the request body (without sending) for the session's
+ * current config, preamble, and tool set, for a single one-shot prompt.
+ * Caller frees the returned string. */
+char *provider_preview_request(session *sess, const char *prompt,
+ bool with_tools);
+
+/* On stop, an assistant message can carry more tool calls than the
+ * tool-result messages that follow it (we stopped mid-loop). Trim the
+ * surplus calls so the committed history stays a valid request. */
+void provider_trim_dangling_calls(message *msgs, size_t n);
+
+#endif /* LLSH_PROVIDER_H */
diff --git a/src/session.cpp b/src/session.cpp
new file mode 100644
index 0000000..7e82173
--- /dev/null
+++ b/src/session.cpp
@@ -0,0 +1,1555 @@
+#include "session.h"
+#include <unistd.h>
+#include <stdlib.h>
+#ifndef LLSH_DBG
+#define LLSH_DBG() (getenv("LLSH_DBG")!=NULL)
+#define dbgf(...) do{ if(LLSH_DBG()) fprintf(stderr, __VA_ARGS__); }while(0)
+#endif
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+#include <sys/stat.h>
+
+#include <cjson/cJSON.h>
+
+#include "escape.h"
+
+#include "util.h"
+#include "config.h"
+#include "dotagents.h"
+#include "message.h"
+#include "compact.h"
+#include "goal.h"
+#include "agent.h"
+#include "tools.h"
+#include "llm.h"
+
+/* ANSI colors (session.rs), from escape.h. */
+#define DIM ESC_DIM
+#define RESET ESC_RESET
+#define RED ESC_COLOR_RED
+#define YELLOW ESC_COLOR_YELLOW
+
+#define SNAPSHOT_VERSION 1UL
+
+/* ------------------------------------------------------------------ */
+/* Small local helpers */
+/* ------------------------------------------------------------------ */
+
+static bool trimmed_nonempty(const char *s) {
+ if (!s)
+ return false;
+ while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r')
+ s++;
+ return *s != 0;
+}
+
+static const char *json_str(const cJSON *v, const char *key) {
+ const cJSON *item = cJSON_GetObjectItem(v, key);
+ if (cJSON_IsString(item) && item->valuestring)
+ return item->valuestring;
+ return "";
+}
+
+static config config_clone(const config *c) {
+ config out;
+ out.provider = c->provider;
+ out.model = c->model ? xstrdup(c->model) : NULL;
+ out.base_url = c->base_url ? xstrdup(c->base_url) : NULL;
+ out.api_key = c->api_key ? xstrdup(c->api_key) : NULL;
+ out.preamble = c->preamble ? xstrdup(c->preamble) : NULL;
+ out.max_turns = c->max_turns;
+ out.max_retries = c->max_retries;
+ out.context_limit = c->context_limit;
+ out.compact_threshold = c->compact_threshold;
+ out.clear_threshold = c->clear_threshold;
+ out.verbose = c->verbose;
+ out.skill_index_cap = c->skill_index_cap;
+ out.shell_timeout_secs = c->shell_timeout_secs;
+ out.max_output_chars = c->max_output_chars;
+ out.max_file_bytes = c->max_file_bytes;
+ sl_init(&out.shell_allowlist);
+ for (size_t i = 0; i < c->shell_allowlist.len; i++)
+ sl_push(&out.shell_allowlist, xstrdup(c->shell_allowlist.items[i]));
+ sl_init(&out.shell_denylist);
+ for (size_t i = 0; i < c->shell_denylist.len; i++)
+ sl_push(&out.shell_denylist, xstrdup(c->shell_denylist.items[i]));
+ out.confirm_destructive = c->confirm_destructive;
+ return out;
+}
+
+/* ------------------------------------------------------------------ */
+/* Free helpers */
+/* ------------------------------------------------------------------ */
+
+void sess_emit_free(sess_emit *e) {
+ if (!e)
+ return;
+ free(e->text);
+ e->text = NULL;
+}
+
+void sess_error_clear(sess_error *e) {
+ if (!e)
+ return;
+ free(e->message);
+ e->message = NULL;
+ e->http_status = 0;
+ e->transport = 0;
+ e->retry_after_secs = 0;
+}
+
+void sess_retry_free(sess_retry *r) {
+ if (!r)
+ return;
+ free(r->reason);
+ free(r);
+}
+
+/* ------------------------------------------------------------------ */
+/* Pure helpers */
+/* ------------------------------------------------------------------ */
+
+bool sess_is_transient_status(int status) {
+ return status == 408 || status == 429 || status == 500 || status == 502 ||
+ status == 503 || status == 504 || status == 529;
+}
+
+unsigned long sess_backoff_secs(unsigned long attempt) {
+ unsigned long exp = attempt > 1 ? attempt - 1 : 0;
+ if (exp > 5)
+ exp = 5;
+ unsigned long ms = 1000UL << exp;
+ if (ms > 30000)
+ ms = 30000;
+ return ms / 1000;
+}
+
+unsigned long sess_human_secs(unsigned long secs) {
+ return secs < 1 ? 1 : secs;
+}
+
+sess_retry *sess_classify_retry(const sess_error *e) {
+ if (!e)
+ return NULL;
+ if (e->http_status) {
+ if (sess_is_transient_status(e->http_status)) {
+ sess_retry *r = (sess_retry *) xmalloc(sizeof *r);
+ r->reason = xstrfmt("HTTP %d", e->http_status);
+ unsigned long ra = e->retry_after_secs;
+ if (ra > 60)
+ ra = 60;
+ r->retry_after_secs = ra;
+ r->has_retry_after = ra > 0;
+ return r;
+ }
+ return NULL;
+ }
+ if (e->transport) {
+ sess_retry *r = (sess_retry *) xmalloc(sizeof *r);
+ r->reason = xstrdup("transient connection error");
+ r->retry_after_secs = 0;
+ r->has_retry_after = false;
+ return r;
+ }
+ return NULL;
+}
+
+bool sess_error_is_context_overflow(const sess_error *e) {
+ if (!e || !e->message)
+ return false;
+ if (e->http_status == 413)
+ return true;
+ if (!e->http_status || e->http_status < 400 || e->http_status >= 500)
+ return false;
+ if (llm_strcasestr(e->message, "context") ||
+ llm_strcasestr(e->message, "exceed"))
+ return true;
+ return false;
+}
+
+int sess_assistant_emit(const char *kind, const char *text, const char *args,
+ sess_emit *out) {
+ if (xstreq(kind, "text")) {
+ if (out) {
+ *out = (sess_emit){0};
+ out->kind = SESS_EMIT_TEXT;
+ out->text = text ? xstrdup(text) : NULL;
+ }
+ return 0;
+ }
+ if (xstreq(kind, "reasoning")) {
+ if (out) {
+ *out = (sess_emit){0};
+ out->kind = SESS_EMIT_REASONING;
+ out->text = text ? xstrdup(text) : NULL;
+ }
+ return 0;
+ }
+ if (xstreq(kind, "tool_call")) {
+ /* One-line transcript display. Tools with a custom renderer in the
+ * display vtable (e.g. run_shell -> `$ <command>`) use it; everything
+ * else falls back to `# name <args>`, where <args> is the raw JSON the
+ * model sent (tool params are a JSON object). `# name` is yellow, the
+ * args are dimmed. Presentation only -- the call is executed directly
+ * by exec_builtin. */
+ const char *name = text ? text : "";
+ const char *a = (args && args[0]) ? args : NULL;
+ char *body = NULL;
+ const tool_display *op = tool_display_for(name);
+ if (op && op->render_call)
+ body = op->render_call(a);
+ if (!body)
+ body = a
+ ? xstrfmt("\n%s# %s%s %s%s%s\n", YELLOW, name, RESET, DIM,
+ a, RESET)
+ : xstrfmt("\n%s# %s%s\n", YELLOW, name, RESET);
+ if (out) {
+ *out = (sess_emit){0};
+ out->kind = SESS_EMIT_VERBATIM;
+ out->text = body;
+ } else
+ free(body);
+ return 0;
+ }
+ return 1;
+}
+
+int sess_tool_result_emit(const char *tool, const char *result,
+ sess_emit *out) {
+ if (!result || !result[0] || !out)
+ return 1;
+ size_t len = strlen(result);
+ while (len > 0 && result[len - 1] == '\n')
+ len--;
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_putn(&b, result, len);
+ xbuf_push(&b, '\n');
+ char *body = xbuf_steal(&b);
+ xbuf_free(&b);
+ *out = (sess_emit){0};
+ out->kind = SESS_EMIT_RESULT;
+ out->text = body;
+ /* Shell-style tools (per the display vtable) render their results
+ * muted; other tool results stay in the normal color. */
+ const tool_display *op = tool ? tool_display_for(tool) : NULL;
+ out->from_shell = op != NULL && op->dim_result;
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Preamble / preset / profile helpers */
+/* ------------------------------------------------------------------ */
+
+char *session_active_preset_from(const dotagents *dot) {
+ if (dot && dot->has_models && dot->models.default_name &&
+ dot->models.default_name[0])
+ return xstrdup(dot->models.default_name);
+ return NULL;
+}
+
+char *sess_base_preamble_for(const config *cfg, const dotagents *dot) {
+ const char *base = trimmed_nonempty(dot->system_prompt)
+ ? dot->system_prompt
+ : (cfg->preamble ? cfg->preamble : "");
+ return dotagents_compose_preamble(dot, base, cfg->skill_index_cap);
+}
+
+char *sess_preamble_for_profile(const dotagents *dot, const config *cfg,
+ const char *profile) {
+ if (profile) {
+ const sub_agent *a = dotagents_enabled_sub_agent(dot, profile);
+ if (a) {
+ char *body = xstr_rtrim_dup(a->body ? a->body : "");
+ char *sections = dotagents_project_sections(dot, cfg->skill_index_cap);
+ char *r = xstrjoin2(body, sections);
+ free(body);
+ free(sections);
+ return r;
+ }
+ }
+ return sess_base_preamble_for(cfg, dot);
+}
+
+int sess_apply_overrides(config *cfg, const char *model, const char *provider,
+ const char *base_url, const char *api_key,
+ bool strict, char **err) {
+ if (provider) {
+ char *perr = NULL;
+ provider_kind pk = provider_parse(provider, &perr);
+ if (perr) {
+ if (strict) {
+ if (err)
+ *err = perr;
+ else
+ free(perr);
+ return -1;
+ }
+ free(perr);
+ } else {
+ cfg->provider = pk;
+ }
+ }
+ if (model) {
+ free(cfg->model);
+ cfg->model = xstrdup(model);
+ }
+ if (base_url) {
+ free(cfg->base_url);
+ cfg->base_url = xstrdup(base_url);
+ }
+ if (api_key) {
+ free(cfg->api_key);
+ cfg->api_key = xstrdup(api_key);
+ }
+ return 0;
+}
+
+config sess_effective_for_profile(const config *cfg, const dotagents *dot,
+ const char *profile) {
+ config out = config_clone(cfg);
+ if (profile) {
+ const sub_agent *a = dotagents_enabled_sub_agent(dot, profile);
+ if (a && a->config) {
+ cJSON *root = cJSON_Parse(a->config);
+ if (root) {
+ const char *model = json_str(root, "model");
+ const char *provider = json_str(root, "provider");
+ const char *base_url = json_str(root, "base_url");
+ const char *api_key = json_str(root, "api_key");
+ if (provider[0]) {
+ char *perr = NULL;
+ provider_kind pk = provider_parse(provider, &perr);
+ if (perr)
+ free(perr);
+ else
+ out.provider = pk;
+ }
+ if (model[0]) {
+ free(out.model);
+ out.model = xstrdup(model);
+ }
+ if (base_url[0]) {
+ free(out.base_url);
+ out.base_url = xstrdup(base_url);
+ }
+ if (api_key[0]) {
+ free(out.api_key);
+ out.api_key = xstrdup(api_key);
+ }
+ cJSON_Delete(root);
+ }
+ }
+ }
+ return out;
+}
+
+/* ------------------------------------------------------------------ */
+/* Session state */
+/* ------------------------------------------------------------------ */
+
+struct session {
+ config cfg;
+ dotagents dot;
+ history hist;
+ char *base_preamble;
+ char *active_preset;
+ char *profile;
+ compact_config compact;
+ unsigned long last_context_tokens;
+ bool summary_attempted_this_turn;
+ /* Rubric compaction (SelfCompact) bookkeeping. */
+ unsigned long total_rounds; /* completed tool rounds (the "iteration") */
+ unsigned long rounds_since_probe;
+ unsigned long rubric_summaries;
+ char *debug_path; /* --debug: history is dumped here (NULL = off) */
+ llsh_model *model;
+ llsh_model *summarizer;
+ llsh_model *goal_evaluator;
+ llsh_model *rubric; /* SelfCompact judge (PROV_ROLE_RUBRIC) */
+ goal_report *goal; /* name avoids the C++ type/member clash */
+ /* Cached effective state for (cfg, dot, profile). Rebuilt lazily;
+ * invalidated whenever any of the three changes. */
+ config active;
+ bool active_valid;
+ char *active_preamble;
+ strlist active_allowed;
+};
+
+static void session_active_reset(session *s) {
+ if (s->active_valid) {
+ config_free(&s->active);
+ s->active_valid = false;
+ }
+ free(s->active_preamble);
+ s->active_preamble = NULL;
+ sl_free(&s->active_allowed);
+}
+
+static void session_active_build(session *s) {
+ if (s->active_valid)
+ return;
+ s->active = sess_effective_for_profile(&s->cfg, &s->dot, s->profile);
+ s->active_preamble = sess_preamble_for_profile(&s->dot, &s->cfg,
+ s->profile);
+ if (s->profile) {
+ const sub_agent *a = dotagents_enabled_sub_agent(&s->dot, s->profile);
+ if (a && a->config) {
+ cJSON *root = cJSON_Parse(a->config);
+ if (root) {
+ cJSON *tools = cJSON_GetObjectItem(root, "tools");
+ if (cJSON_IsArray(tools)) {
+ const cJSON *t = NULL;
+ cJSON_ArrayForEach(t, tools)
+ if (cJSON_IsString(t) && t->valuestring)
+ sl_push_copy(&s->active_allowed, t->valuestring);
+ }
+ cJSON_Delete(root);
+ }
+ }
+ }
+ s->active_valid = true;
+}
+
+session *session_new_with_model(llsh_model *model, config cfg, dotagents dot) {
+ session *s = (session *) xmalloc(sizeof *s);
+ new (s) session{};
+ s->model = model;
+ s->cfg = cfg;
+ s->dot = dot;
+ s->base_preamble = sess_base_preamble_for(&cfg, &dot);
+ s->active_preset = session_active_preset_from(&dot);
+ s->profile = NULL;
+ s->last_context_tokens = 0;
+ s->summary_attempted_this_turn = false;
+ s->total_rounds = 0;
+ s->rounds_since_probe = 0;
+ s->rubric_summaries = 0;
+ s->debug_path = NULL;
+ s->summarizer = NULL;
+ s->goal_evaluator = NULL;
+ s->goal = NULL;
+ compact_config_from(cfg.context_limit, cfg.compact_threshold,
+ cfg.clear_threshold, &s->compact);
+ return s;
+}
+
+void session_free(session *s) {
+ if (!s)
+ return;
+ session_active_reset(s);
+ history_free(&s->hist);
+ free(s->debug_path);
+ free(s->base_preamble);
+ free(s->active_preset);
+ free(s->profile);
+ goal_report_free(s->goal);
+ llsh_model_free(s->model);
+ llsh_model_free(s->summarizer);
+ llsh_model_free(s->goal_evaluator);
+ llsh_model_free(s->rubric);
+ config_free(&s->cfg);
+ dotagents_free(&s->dot);
+ free(s);
+}
+
+session *session_with_summarizer(session *s, llsh_model *summarizer) {
+ llsh_model_free(s->summarizer);
+ s->summarizer = summarizer;
+ return s;
+}
+
+session *session_with_goal_evaluator(session *s, llsh_model *evaluator) {
+ llsh_model_free(s->goal_evaluator);
+ s->goal_evaluator = evaluator;
+ return s;
+}
+
+session *session_with_rubric(session *s, llsh_model *rubric) {
+ llsh_model_free(s->rubric);
+ s->rubric = rubric;
+ return s;
+}
+
+/* ------------------------------------------------------------------ */
+/* Accessors */
+/* ------------------------------------------------------------------ */
+
+char *session_model(session *s) {
+ const config *cfg = session_active_config(s);
+ return xstrdup(cfg->model ? cfg->model : "");
+}
+
+const config *session_config(const session *s) {
+ return &s->cfg;
+}
+
+char *session_active_preset(session *s) {
+ return s->active_preset ? xstrdup(s->active_preset) : NULL;
+}
+
+char *session_profile_name(session *s) {
+ return s->profile ? xstrdup(s->profile) : NULL;
+}
+
+const dotagents *session_dot(const session *s) {
+ return &s->dot;
+}
+
+const strlist *session_allowed_tools(session *s) {
+ session_active_build(s);
+ return &s->active_allowed;
+}
+
+unsigned long session_context_tokens(session *s) {
+ if (s->last_context_tokens > 0)
+ return s->last_context_tokens;
+ return compact_estimate_tokens(s->hist.msgs.data(), s->hist.msgs.size());
+}
+
+unsigned long session_context_limit(session *s) {
+ return s->compact.context_limit;
+}
+
+const goal_report *session_goal_report(session *s) {
+ return s->goal;
+}
+
+void session_set_goal_report(session *s, goal_report *r) {
+ goal_report_free(s->goal);
+ s->goal = r;
+}
+
+char *session_last_assistant_text(session *s) {
+ for (size_t i = s->hist.msgs.size(); i-- > 0;) {
+ const message *m = &s->hist.msgs[i];
+ if (!m->is_assistant())
+ continue;
+ xbuf b;
+ xbuf_init(&b);
+ bool first = true;
+ for (size_t j = 0; j < m->assistant().parts.size(); j++) {
+ const auto &p = m->assistant().parts[j];
+ const asst_text_part *t = std::get_if<asst_text_part>(&p);
+ if (t && !t->text.empty()) {
+ if (!first)
+ xbuf_push(&b, '\n');
+ xbuf_put(&b, t->text.c_str());
+ first = false;
+ }
+ }
+ char *text = xbuf_steal(&b);
+ xbuf_free(&b);
+ const char *p = text;
+ while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')
+ p++;
+ if (*p)
+ return text;
+ free(text);
+ }
+ return NULL;
+}
+
+const char *session_current_preamble(session *s) {
+ session_active_build(s);
+ return s->active_preamble ? s->active_preamble : "";
+}
+
+const config *session_active_config(session *s) {
+ session_active_build(s);
+ return &s->active;
+}
+
+int session_refresh_context_limit(session *s) {
+ unsigned long limit = agent_fetch_context_limit(&s->cfg);
+ if (limit > 0) {
+ s->cfg.context_limit = limit;
+ s->compact.context_limit = limit;
+ return 1;
+ }
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* History */
+/* ------------------------------------------------------------------ */
+
+size_t session_history_len(session *s) {
+ return s->hist.msgs.size();
+}
+
+const message *session_history_at(session *s, size_t i) {
+ return i < s->hist.msgs.size() ? &s->hist.msgs[i] : NULL;
+}
+
+void session_push_history(session *s, message m) {
+ s->hist.msgs.push_back(std::move(m));
+}
+
+unsigned long session_last_context_tokens(session *s) {
+ return s->last_context_tokens;
+}
+
+void session_set_last_context_tokens(session *s, unsigned long t) {
+ s->last_context_tokens = t;
+}
+
+void session_clear_history(session *s) {
+ s->hist.msgs.clear();
+ s->last_context_tokens = 0;
+ s->total_rounds = 0;
+ s->rounds_since_probe = 0;
+ s->rubric_summaries = 0;
+}
+
+int session_undo_last_turn(session *s) {
+ size_t n = s->hist.msgs.size();
+ size_t last_user = compact_keep_boundary(s->hist.msgs.data(), n);
+ if (last_user >= n)
+ return -1;
+ size_t removed = n - last_user;
+ s->hist.msgs.erase(s->hist.msgs.begin() + static_cast<long>(last_user),
+ s->hist.msgs.end());
+ s->last_context_tokens = 0;
+ return (int)removed;
+}
+
+size_t session_message_count(session *s) {
+ return s->hist.msgs.size();
+}
+
+char *session_transcript_markdown(session *s) {
+ return compact_transcript_markdown(s->hist.msgs.data(), s->hist.msgs.size());
+}
+
+/* ------------------------------------------------------------------ */
+/* Compaction */
+/* ------------------------------------------------------------------ */
+
+void session_print_compact(const compact_report *report) {
+ const char *icon, *label;
+ if (report->tier == TIER_CLEARED) {
+ icon = "\xE2\x97\x88";
+ label = "cleared stale tool output";
+ } else if (report->tier == TIER_SUMMARIZED) {
+ icon = "\xE2\x97\x86";
+ label = "summarized earlier context";
+ } else {
+ icon = "!";
+ label = "summarization failed; cleared stale tool output";
+ }
+ char *details;
+ if (report->tier == TIER_SUMMARIZED)
+ details = xstrfmt("%zu file(s) re-injected", report->files_reinjected);
+ else
+ details = xstrfmt("%zu tool result(s) cleared",
+ report->cleared_tool_results);
+ char *line = xstrfmt("\n" DIM "%s %s: %lu \xE2\x86\x92 %lu tokens (%s)" RESET
+ "\n",
+ icon, label, report->tokens_before,
+ report->tokens_after, details);
+ free(details);
+ fputs(line, stdout);
+ fflush(stdout);
+ free(line);
+}
+
+int session_compact_if_needed(session *s, bool force, compact_report *report,
+ char **err) {
+ dbgf("CFN enter force=%d n=%zu limit=%lu\n",(int)force,s->hist.msgs.size(),s->compact.context_limit);
+ (void)err;
+ size_t n = s->hist.msgs.size();
+ if (n == 0)
+ return 0;
+ if (s->compact.context_limit == 0 && !force)
+ return 0;
+
+ unsigned long tokens = s->last_context_tokens > 0
+ ? s->last_context_tokens
+ : compact_estimate_tokens(s->hist.msgs.data(), n);
+
+ if (!force) {
+ unsigned long cbudget, kbudget;
+ bool clear_due = clear_at(&s->compact, &cbudget) && tokens >= cbudget;
+ bool compact_due =
+ compact_at(&s->compact, &kbudget) && tokens >= kbudget;
+ if (!clear_due && !compact_due)
+ return 0;
+ }
+
+ size_t boundary = compact_keep_boundary(s->hist.msgs.data(), n);
+ dbgf("CFN boundary=%zu tokens=%lu last_tok=%lu\n",boundary,tokens,s->last_context_tokens);
+ if (boundary == 0 || boundary >= n)
+ return 0;
+
+ fputs("\n" RED "~ compaction" RESET "\n", stdout);
+ fflush(stdout);
+
+ unsigned long kb;
+ bool want_summary =
+ (force || (compact_at(&s->compact, &kb) && tokens >= kb)) &&
+ !s->summary_attempted_this_turn;
+
+ dbgf("CFN want_summary=%d\n",(int)want_summary);
+ if (!want_summary) {
+ size_t cleared =
+ compact_clear_old_tool_results(s->hist.msgs.data(), n, boundary);
+ if (report) {
+ report->tier = TIER_CLEARED;
+ report->tokens_before = tokens;
+ report->tokens_after =
+ compact_estimate_tokens(s->hist.msgs.data(), n);
+ report->cleared_tool_results = cleared;
+ report->files_reinjected = 0;
+ }
+ s->last_context_tokens = 0;
+ return 1;
+ }
+
+ /* stale = msgs[0..boundary); tail = msgs[boundary..n) */
+ const message *stale = s->hist.msgs.data();
+
+ char *summary = NULL;
+ bool summary_ok = false;
+ if (s->summarizer) {
+ s->summary_attempted_this_turn = true;
+ char *transcript = compact_transcript(stale, boundary);
+ sess_error se = {0};
+ sess_error_clear(&se);
+ summary = s->summarizer->complete(s->summarizer, transcript, &se);
+ dbgf("CFN summarizer called -> summary=%p\n",(void*)summary);
+ free(transcript);
+ if (summary)
+ summary_ok = true;
+ else
+ sess_error_clear(&se);
+ }
+
+ dbgf("CFN summary_ok=%d\n",(int)summary_ok);
+ if (summary_ok) {
+ strlist paths;
+ sl_init(&paths);
+ compact_recent_file_paths(summary, &paths);
+ const char **file_paths = NULL;
+ const char **file_contents = NULL;
+ size_t nfiles = 0;
+ if (paths.len) {
+ file_paths = (const char* * ) xmalloc(paths.len * sizeof *file_paths);
+ file_contents = (const char* * ) xmalloc(paths.len * sizeof *file_contents);
+ }
+ for (size_t i = 0; i < paths.len; i++) {
+ char *content = NULL;
+ size_t clen = 0;
+ char *ferr = NULL;
+ if (read_file_to_buf(paths.items[i], &content, &clen, &ferr) == 0) {
+ file_paths[nfiles] = xstrdup(paths.items[i]);
+ file_contents[nfiles] = content;
+ nfiles++;
+ }
+ free(ferr);
+ }
+ sl_free(&paths);
+
+ const message *tail = s->hist.msgs.data() + boundary;
+ history *newhist = compact_compose_history(
+ summary, file_paths, file_contents, nfiles, tail, n - boundary);
+ s->hist.msgs = std::move(newhist->msgs);
+ history_free(newhist);
+ dbgf("CFN composed newhist len=%zu\n",s->hist.msgs.size());
+ free(newhist);
+ free(summary);
+ for (size_t i = 0; i < nfiles; i++) {
+ free((char *)file_paths[i]);
+ free((char *)file_contents[i]);
+ }
+ free(file_paths);
+ free(file_contents);
+
+ if (report) {
+ report->tier = TIER_SUMMARIZED;
+ report->tokens_before = tokens;
+ report->tokens_after = compact_estimate_tokens(
+ s->hist.msgs.data(), s->hist.msgs.size());
+ report->cleared_tool_results = 0;
+ report->files_reinjected = nfiles;
+ }
+ s->last_context_tokens = 0;
+ return 1;
+ }
+
+ /* fallback: clear stale tool results in place */
+ size_t cleared = compact_clear_old_tool_results(s->hist.msgs.data(),
+ boundary, boundary);
+ if (report) {
+ report->tier = TIER_FALLBACK_CLEAR;
+ report->tokens_before = tokens;
+ report->tokens_after =
+ compact_estimate_tokens(s->hist.msgs.data(), n);
+ report->cleared_tool_results = cleared;
+ report->files_reinjected = 0;
+ }
+ fprintf(stderr, DIM
+ "[compact] summarization failed; cleared stale tool results instead"
+ RESET "\n");
+ s->last_context_tokens = 0;
+ return 1;
+}
+
+/* ------------------------------------------------------------------ */
+/* Rubric compaction (SelfCompact, arXiv:2606.23525) */
+/* ------------------------------------------------------------------ */
+
+void session_note_tool_round(session *s) {
+ if (!s)
+ return;
+ s->total_rounds++;
+ s->rounds_since_probe++;
+}
+
+int session_force_compact(session *s, compact_report *report, char **err) {
+ if (!s) {
+ if (err)
+ *err = xstrdup("no session");
+ return -1;
+ }
+ return session_compact_if_needed(s, true, report, err);
+}
+
+int session_rubric_probe(session *s, char **err) {
+ if (err)
+ *err = NULL;
+ if (!s || !s->cfg.rubric_enabled || !s->rubric)
+ return 0;
+
+ unsigned long tokens = s->last_context_tokens > 0
+ ? s->last_context_tokens
+ : compact_estimate_tokens(s->hist.msgs.data(),
+ s->hist.msgs.size());
+ if (!compact_rubric_due(s->total_rounds, tokens, s->rubric_summaries,
+ s->cfg.rubric_max_summaries,
+ s->rounds_since_probe))
+ return 0;
+
+ /* Probe the model with the whole trajectory plus the search rubric. */
+ char *transcript = session_transcript_markdown(s);
+ if (!transcript)
+ return 0;
+ char *prompt = xstrfmt("%s\n\n%s", transcript, compact_rubric_prompt());
+ free(transcript);
+ sess_error se = {0};
+ sess_error_clear(&se);
+ char *answer = s->rubric->complete(s->rubric, prompt, &se);
+ free(prompt);
+ if (!answer) {
+ char *msg = xstrdup(se.message ? se.message : "rubric probe failed");
+ sess_error_clear(&se);
+ if (err)
+ *err = msg;
+ else
+ free(msg);
+ return -1;
+ }
+
+ compact_rubric r;
+ memset(&r, 0, sizeof r);
+ char *perr = NULL;
+ int prc = compact_rubric_parse(answer, &r, &perr);
+ free(answer);
+ if (prc != 0) {
+ char *msg = xstrdup(perr ? perr : "unparseable rubric verdict");
+ free(perr);
+ if (err)
+ *err = msg;
+ else
+ free(msg);
+ return -1;
+ }
+
+ /* A probe was made: restart the round gate regardless of the verdict. */
+ s->rounds_since_probe = 0;
+ if (!compact_rubric_fire(&r))
+ return 0;
+
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ int rc = session_compact_if_needed(s, true, &report, err);
+ if (rc > 0) {
+ s->rubric_summaries++;
+ session_print_compact(&report);
+ }
+ return rc > 0 ? 1 : 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Goal */
+/* ------------------------------------------------------------------ */
+
+int session_evaluate_goal(session *s, const goal_spec *spec,
+ const char *last_reply, goal_verdict *out,
+ char **err) {
+ if (!s->goal_evaluator) {
+ if (err)
+ *err = xstrdup("no goal evaluator configured");
+ return -1;
+ }
+ const char *reply = trimmed_nonempty(last_reply)
+ ? last_reply
+ : "(the agent finished this turn without a text "
+ "reply \xE2\x80\x94 it made only tool calls)";
+ char *prompt = xstrfmt(
+ "GOAL (the success condition):\n%s\n\nAGENT'S LATEST REPLY:\n%s",
+ spec->criteria, reply);
+ sess_error se = {0};
+ sess_error_clear(&se);
+ char *verdict_text = s->goal_evaluator->complete(s->goal_evaluator, prompt,
+ &se);
+ free(prompt);
+ if (!verdict_text) {
+ char *msg = xstrfmt("asking the goal evaluator: %s",
+ se.message ? se.message : "unknown");
+ sess_error_clear(&se);
+ if (err)
+ *err = msg;
+ else
+ free(msg);
+ return -1;
+ }
+ goal_parse_verdict(verdict_text, out);
+ free(verdict_text);
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Model / profile */
+/* ------------------------------------------------------------------ */
+
+static int session_apply_preset(session *s, const char *name, char **err) {
+ const model_preset *p =
+ (s->dot.has_models) ? models_file_get(&s->dot.models, name) : NULL;
+ if (!p) {
+ char *e = xstrfmt("preset `%s` not found", name);
+ if (err)
+ *err = e;
+ else
+ free(e);
+ return -1;
+ }
+ if (sess_apply_overrides(&s->cfg, p->model, p->provider, p->base_url,
+ p->api_key, true, err) != 0)
+ return -1;
+ if (p->has_max_turns)
+ s->cfg.max_turns = p->max_turns;
+ if (p->has_context_limit)
+ s->cfg.context_limit = p->context_limit;
+ compact_config_from(s->cfg.context_limit, s->cfg.compact_threshold,
+ s->cfg.clear_threshold, &s->compact);
+ s->last_context_tokens = 0;
+ if (s->cfg.provider != PROV_OLLAMA && !s->cfg.base_url &&
+ !s->cfg.api_key) {
+ char *e = xstrfmt("no API key for provider `%s` from preset `%s`",
+ provider_name(s->cfg.provider), name);
+ if (err)
+ *err = e;
+ else
+ free(e);
+ return -1;
+ }
+ free(s->active_preset);
+ s->active_preset = xstrdup(name);
+ session_active_reset(s);
+ return 0;
+}
+
+int session_set_model(session *s, const char *name, char **err) {
+ if (s->dot.has_models && models_file_get(&s->dot.models, name) != NULL)
+ return session_apply_preset(s, name, err);
+ free(s->active_preset);
+ s->active_preset = NULL;
+ free(s->cfg.model);
+ s->cfg.model = xstrdup(name);
+ s->last_context_tokens = 0;
+ session_active_reset(s);
+ return 0;
+}
+
+int session_set_profile(session *s, const char *id, char **err) {
+ if (id) {
+ if (!dotagents_enabled_sub_agent(&s->dot, id)) {
+ xbuf b;
+ xbuf_init(&b);
+ bool first = true;
+ for (size_t i = 0; i < s->dot.sub_agent_count; i++) {
+ if (!first)
+ xbuf_put(&b, ", ");
+ xbuf_put(&b, s->dot.sub_agents[i].id);
+ first = false;
+ }
+ char *known = s->dot.sub_agent_count ? xbuf_steal(&b)
+ : xstrdup("none defined");
+ xbuf_free(&b);
+ char *e = xstrfmt("no enabled sub-agent `%s` (known: %s)", id,
+ known);
+ free(known);
+ if (err)
+ *err = e;
+ else
+ free(e);
+ return -1;
+ }
+ }
+ free(s->profile);
+ s->profile = id ? xstrdup(id) : NULL;
+ session_active_reset(s);
+ return 0;
+}
+
+int session_refresh_dot(session *s, const char *workspace, const char *global,
+ char **err) {
+ (void)err;
+ dotagents fresh;
+ dotagents_load_from(&fresh, workspace, global);
+ char *bp = sess_base_preamble_for(&s->cfg, &fresh);
+ dotagents_free(&s->dot);
+ s->dot = fresh;
+ free(s->base_preamble);
+ s->base_preamble = bp;
+ session_active_reset(s);
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Save / load / resume */
+/* ------------------------------------------------------------------ */
+
+void session_snapshot_free(session_snapshot *snap) {
+ if (!snap)
+ return;
+ free(snap->cwd);
+ config_free(&snap->cfg);
+ history_free(&snap->hist);
+ free(snap->active_preset);
+ free(snap->profile);
+ free(snap);
+}
+
+static unsigned long long jnum(const nlohmann::json &j, const char *key) {
+ if (j.contains(key) && j.at(key).is_number())
+ return (unsigned long long) j.at(key).get<double>();
+ return 0;
+}
+
+static nlohmann::json snapshot_to_json(const session_snapshot *snap) {
+ nlohmann::json cfg = nlohmann::json::object();
+ cfg["model"] = snap->cfg.model ? snap->cfg.model : "";
+ cfg["provider"] = provider_name(snap->cfg.provider);
+ if (snap->cfg.base_url)
+ cfg["base_url"] = snap->cfg.base_url;
+ if (snap->cfg.api_key)
+ cfg["api_key"] = snap->cfg.api_key;
+ cfg["max_turns"] = snap->cfg.max_turns;
+ cfg["max_retries"] = snap->cfg.max_retries;
+ cfg["context_limit"] = snap->cfg.context_limit;
+ cfg["compact_threshold"] = snap->cfg.compact_threshold;
+ cfg["clear_threshold"] = snap->cfg.clear_threshold;
+ cfg["verbose"] = snap->cfg.verbose;
+ cfg["skill_index_cap"] = snap->cfg.skill_index_cap;
+ cfg["confirm_destructive"] = snap->cfg.confirm_destructive;
+ cfg["shell_allowlist"] = nlohmann::json::array();
+ for (size_t i = 0; i < snap->cfg.shell_allowlist.len; i++)
+ cfg["shell_allowlist"].push_back(snap->cfg.shell_allowlist.items[i]);
+ cfg["shell_denylist"] = nlohmann::json::array();
+ for (size_t i = 0; i < snap->cfg.shell_denylist.len; i++)
+ cfg["shell_denylist"].push_back(snap->cfg.shell_denylist.items[i]);
+ nlohmann::json hist = nlohmann::json::array();
+ for (const auto &m : snap->hist.msgs)
+ hist.push_back(m);
+ nlohmann::json o;
+ o["version"] = snap->version;
+ o["cwd"] = snap->cwd ? snap->cwd : "";
+ o["saved_at"] = snap->saved_at;
+ o["config"] = cfg;
+ o["history"] = std::move(hist);
+ if (snap->active_preset)
+ o["active_preset"] = snap->active_preset;
+ if (snap->profile)
+ o["profile"] = snap->profile;
+ o["last_context_tokens"] = snap->last_context_tokens;
+ return o;
+}
+
+static session_snapshot *json_to_snapshot(const nlohmann::json &o) {
+ session_snapshot *snap = (session_snapshot *) xmalloc(sizeof *snap);
+ new (snap) session_snapshot{};
+ snap->version = (unsigned long) jnum(o, "version");
+ sl_init(&snap->cfg.shell_allowlist);
+ sl_init(&snap->cfg.shell_denylist);
+ {
+ std::string cwd = jstring(o, "cwd");
+ snap->cwd = xstrdup(cwd.c_str());
+ }
+ snap->saved_at = (unsigned long long) jnum(o, "saved_at");
+ /* config */
+ if (o.contains("config") && o.at("config").is_object()) {
+ const nlohmann::json &cfg = o.at("config");
+ snap->cfg.model = xstrdup(jstring(cfg, "model").c_str());
+ std::string provider = jstring(cfg, "provider");
+ char *perr = NULL;
+ snap->cfg.provider = provider.empty()
+ ? PROV_OPENAI
+ : provider_parse(provider.c_str(), &perr);
+ free(perr);
+ std::string base_url = jstring(cfg, "base_url");
+ if (!base_url.empty())
+ snap->cfg.base_url = xstrdup(base_url.c_str());
+ std::string api_key = jstring(cfg, "api_key");
+ if (!api_key.empty())
+ snap->cfg.api_key = xstrdup(api_key.c_str());
+ snap->cfg.max_turns = (unsigned long) jnum(cfg, "max_turns");
+ snap->cfg.max_retries = (unsigned long) jnum(cfg, "max_retries");
+ snap->cfg.context_limit = (unsigned long) jnum(cfg, "context_limit");
+ if (cfg.contains("compact_threshold") &&
+ cfg.at("compact_threshold").is_number())
+ snap->cfg.compact_threshold =
+ cfg.at("compact_threshold").get<double>();
+ if (cfg.contains("clear_threshold") &&
+ cfg.at("clear_threshold").is_number())
+ snap->cfg.clear_threshold = cfg.at("clear_threshold").get<double>();
+ if (cfg.contains("verbose") && cfg.at("verbose").is_boolean())
+ snap->cfg.verbose = cfg.at("verbose").get<bool>();
+ snap->cfg.skill_index_cap =
+ (unsigned long) jnum(cfg, "skill_index_cap");
+ if (cfg.contains("confirm_destructive") &&
+ cfg.at("confirm_destructive").is_boolean())
+ snap->cfg.confirm_destructive =
+ cfg.at("confirm_destructive").get<bool>();
+ if (cfg.contains("shell_allowlist") && cfg.at("shell_allowlist").is_array())
+ for (const auto &item : cfg.at("shell_allowlist"))
+ if (item.is_string())
+ sl_push_copy(&snap->cfg.shell_allowlist,
+ item.get_ref<const std::string &>().c_str());
+ if (cfg.contains("shell_denylist") && cfg.at("shell_denylist").is_array())
+ for (const auto &item : cfg.at("shell_denylist"))
+ if (item.is_string())
+ sl_push_copy(&snap->cfg.shell_denylist,
+ item.get_ref<const std::string &>().c_str());
+ }
+ /* history */
+ if (o.contains("history") && o.at("history").is_array())
+ for (const auto &m : o.at("history"))
+ if (m.is_object())
+ snap->hist.msgs.push_back(m.get<message>());
+ std::string ap = jstring(o, "active_preset");
+ if (!ap.empty())
+ snap->active_preset = xstrdup(ap.c_str());
+ std::string prof = jstring(o, "profile");
+ if (!prof.empty())
+ snap->profile = xstrdup(prof.c_str());
+ snap->last_context_tokens = (unsigned long) jnum(o, "last_context_tokens");
+ return snap;
+}
+
+session_snapshot *session_snapshot_of(session *s) {
+ session_snapshot *snap = (session_snapshot *) xmalloc(sizeof *snap);
+ new (snap) session_snapshot{};
+ snap->version = SNAPSHOT_VERSION;
+ char buf[4096];
+ snap->cwd = getcwd(buf, sizeof buf) ? xstrdup(buf) : xstrdup("");
+ struct timespec ts;
+ clock_gettime(CLOCK_REALTIME, &ts);
+ snap->saved_at = (unsigned long long)ts.tv_sec;
+ snap->cfg = config_clone(&s->cfg);
+ for (const auto &m : s->hist.msgs)
+ snap->hist.msgs.push_back(m);
+ snap->active_preset =
+ s->active_preset ? xstrdup(s->active_preset) : NULL;
+ snap->profile = s->profile ? xstrdup(s->profile) : NULL;
+ snap->last_context_tokens = s->last_context_tokens;
+ return snap;
+}
+
+/* Write `raw` to `path` atomically (same-dir temp file + rename). */
+static int write_atomic(const char *path, const char *raw, size_t len,
+ char **err) {
+ /* ensure parent dir(s) exist */
+ const char *slash = strrchr(path, '/');
+ if (slash && slash != path) {
+ char *parent = xstrndup(path, (size_t)(slash - path));
+ if (mkdir_p(parent) != 0) {
+ free(parent);
+ if (err)
+ *err = xstrfmt("creating parent dirs for `%s`: %s", path,
+ strerror(errno));
+ return -1;
+ }
+ free(parent);
+ }
+ char *tmp = xstrfmt("%s.XXXXXX", path);
+ int fd = mkstemp(tmp);
+ int rc = -1;
+ if (fd < 0) {
+ if (err)
+ *err = xstrfmt("creating temporary session file for `%s`: %s",
+ path, strerror(errno));
+ } else if (fchmod(fd, 0600) != 0) {
+ if (err)
+ *err = xstrfmt("setting session file permissions: %s", strerror(errno));
+ close(fd);
+ unlink(tmp);
+ } else {
+ size_t off = 0;
+ while (off < len) {
+ ssize_t n = write(fd, raw + off, len - off);
+ if (n > 0)
+ off += (size_t)n;
+ else if (n < 0 && errno == EINTR)
+ continue;
+ else
+ break;
+ }
+ if (off == len && fsync(fd) == 0 && close(fd) == 0) {
+ rc = rename(tmp, path);
+ if (rc != 0 && err)
+ *err = xstrfmt("renaming session file to `%s`: %s", path,
+ strerror(errno));
+ } else {
+ int e = errno ? errno : EIO;
+ close(fd);
+ errno = e;
+ if (err)
+ *err = xstrfmt("writing session file: %s", strerror(errno));
+ }
+ if (rc != 0)
+ unlink(tmp);
+ }
+ free(tmp);
+ return rc;
+}
+
+void session_set_debug_path(session *s, char *path) {
+ if (!s)
+ return;
+ free(s->debug_path);
+ s->debug_path = path; /* may be NULL (disabled) */
+}
+
+int session_debug_dump(session *s, char **err) {
+ if (!s)
+ return 0;
+ if (!s->debug_path || s->debug_path[0] == '\0')
+ return 0;
+ nlohmann::json hist = nlohmann::json::array();
+ for (const auto &m : s->hist.msgs)
+ hist.push_back(m);
+ std::string raw;
+ try {
+ raw = hist.dump(2);
+ } catch (...) {
+ if (err)
+ *err = xstrdup("encoding debug dump");
+ return -1;
+ }
+ return write_atomic(s->debug_path, raw.c_str(), raw.size(), err);
+}
+
+int session_save(session *s, const char *path, char **err) {
+ session_snapshot *snap = session_snapshot_of(s);
+ std::string raw;
+ try {
+ raw = snapshot_to_json(snap).dump();
+ } catch (...) {
+ session_snapshot_free(snap);
+ if (err)
+ *err = xstrdup("encoding session");
+ return -1;
+ }
+ session_snapshot_free(snap);
+ return write_atomic(path, raw.c_str(), raw.size(), err);
+}
+
+session_snapshot *session_load(const char *path, char **err) {
+ char *raw = NULL;
+ size_t len = 0;
+ char *rerr = NULL;
+ if (read_file_to_buf(path, &raw, &len, &rerr) != 0) {
+ free(rerr);
+ if (raw)
+ free(raw);
+ char *e = xstrfmt("reading %s", path);
+ if (err)
+ *err = e;
+ else
+ free(e);
+ return NULL;
+ }
+ free(rerr);
+ nlohmann::json json;
+ try {
+ json = nlohmann::json::parse(raw);
+ } catch (...) {
+ free(raw);
+ char *e = xstrfmt("parsing %s", path);
+ if (err)
+ *err = e;
+ else
+ free(e);
+ return NULL;
+ }
+ free(raw);
+ long long version = -1;
+ if (json.is_object() && json.contains("version") &&
+ json.at("version").is_number())
+ version = (long long) json.at("version").get<double>();
+ if (version != (long long)SNAPSHOT_VERSION) {
+ char *e = xstrfmt("unsupported session version %lld (this build "
+ "understands %lu)",
+ version, SNAPSHOT_VERSION);
+ if (err)
+ *err = e;
+ else
+ free(e);
+ return NULL;
+ }
+ session_snapshot *snap = json_to_snapshot(json);
+ return snap;
+}
+
+session *session_resume(session_snapshot *snap, dotagents dot,
+ llsh_model *model, char **err) {
+ (void)err;
+ char *profile = NULL;
+ if (snap->profile) {
+ if (dotagents_enabled_sub_agent(&dot, snap->profile)) {
+ profile = xstrdup(snap->profile);
+ } else {
+ fprintf(stderr, "warning: saved sub-agent `%s` is not available "
+ "here; using the main agent\n",
+ snap->profile);
+ }
+ }
+ session *s = (session *) xmalloc(sizeof *s);
+ new (s) session{};
+ s->model = model;
+ s->cfg = config_clone(&snap->cfg);
+ s->dot = dot;
+ s->hist.msgs = snap->hist.msgs;
+ s->base_preamble = sess_base_preamble_for(&s->cfg, &s->dot);
+ s->active_preset =
+ snap->active_preset ? xstrdup(snap->active_preset) : NULL;
+ s->profile = profile;
+ s->last_context_tokens = snap->last_context_tokens;
+ s->summarizer = NULL;
+ s->goal_evaluator = NULL;
+ s->goal = NULL;
+ compact_config_from(s->cfg.context_limit, s->cfg.compact_threshold,
+ s->cfg.clear_threshold, &s->compact);
+ session_snapshot_free(snap);
+ return s;
+}
+
+/* ------------------------------------------------------------------ */
+/* Turn */
+/* ------------------------------------------------------------------ */
+
+static void session_append_streamed(session *s, const char *prompt,
+ char *assistant, message *new_messages,
+ size_t n_new) {
+ if (new_messages && n_new > 0) {
+ for (size_t i = 0; i < n_new; i++)
+ s->hist.msgs.push_back(std::move(new_messages[i]));
+ delete[] new_messages;
+ } else {
+ history_push(&s->hist, msg_user_text(prompt));
+ if (assistant && assistant[0])
+ history_push(&s->hist, msg_assistant_text(assistant));
+ }
+ free(assistant);
+}
+
+static void session_discard_streamed(char *assistant, message *new_messages,
+ size_t n_new) {
+ if (new_messages)
+ delete[] new_messages;
+ free(assistant);
+}
+
+static int session_run_turn_impl(session *s, const char *prompt,
+ std::atomic<bool> *stop, sess_emit_cb cb,
+ void *user, char **err, int *result) {
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *cerr = NULL;
+ s->summary_attempted_this_turn = false;
+ int rc = session_compact_if_needed(s, false, &report, &cerr);
+ if (rc < 0) {
+ if (err)
+ *err = cerr;
+ else
+ free(cerr);
+ return -1;
+ }
+ if (rc == 1)
+ session_print_compact(&report);
+
+ if (stop && stop->load()) {
+ history_push(&s->hist, msg_user_text(prompt));
+ if (result)
+ *result = SESS_TURN_STOPPED;
+ return 0;
+ }
+
+ unsigned long max_retries = s->cfg.max_retries;
+ unsigned long retry = 0;
+ bool overflow_retried = false;
+ for (;;) {
+ sess_error e = {0};
+ sess_error_clear(&e);
+ char *assistant = NULL;
+ unsigned long input_tokens = 0;
+ message *new_messages = NULL;
+ size_t n_new = 0;
+ int rcs = s->model->stream(
+ s->model, s->hist.msgs.data(), s->hist.msgs.size(),
+ prompt, cb, user, stop, &assistant, &input_tokens,
+ &new_messages, &n_new, &e);
+ if (rcs == SESS_STREAM_OK) {
+ session_append_streamed(s, prompt, assistant, new_messages, n_new);
+ if (input_tokens > 0)
+ s->last_context_tokens = input_tokens;
+ /* SelfCompact rubric probe: the turn is complete and the context
+ * size is final. A probe failure never fails the turn. */
+ char *perr = NULL;
+ if (session_rubric_probe(s, &perr) != 0) {
+ fprintf(stderr, DIM "[rubric] %s" RESET "\n",
+ perr ? perr : "probe failed");
+ free(perr);
+ }
+ if (result)
+ *result = SESS_TURN_COMPLETED;
+ return 0;
+ }
+ if (rcs == SESS_STREAM_STOPPED) {
+ session_append_streamed(s, prompt, assistant, new_messages, n_new);
+ sess_error_clear(&e);
+ if (result)
+ *result = SESS_TURN_STOPPED;
+ return 0;
+ }
+ if (rcs == SESS_STREAM_RETRYABLE) {
+ session_discard_streamed(assistant, new_messages, n_new);
+ sess_retry *r = sess_classify_retry(&e);
+ if (retry < max_retries) {
+ retry++;
+ unsigned long delay =
+ (r && r->has_retry_after) ? r->retry_after_secs
+ : sess_backoff_secs(retry);
+ char *notice = xstrfmt(
+ "\n" DIM "[provider error (%s); retry %lu/%lu in %lus]"
+ RESET "\n",
+ r ? r->reason : "unknown", retry, max_retries,
+ sess_human_secs(delay));
+ sess_emit ev;
+ memset(&ev, 0, sizeof ev);
+ ev.kind = SESS_EMIT_VERBATIM;
+ ev.text = notice;
+ if (cb)
+ cb(&ev, user);
+ free(notice);
+ sess_retry_free(r);
+ sess_error_clear(&e);
+ /* Actually wait before retrying; a stop request ends the turn. */
+ unsigned long delay_ms = delay * 1000UL;
+ for (unsigned long slept = 0; slept < delay_ms; slept += 50) {
+ if (stop && stop->load())
+ break;
+ sleep_ms(50);
+ }
+ if (stop && stop->load()) {
+ history_push(&s->hist, msg_user_text(prompt));
+ if (result)
+ *result = SESS_TURN_STOPPED;
+ return 0;
+ }
+ continue;
+ } else {
+ char *msg = xstrfmt("provider error after %lu retries: %s",
+ retry,
+ e.message ? e.message : "unknown");
+ sess_error_clear(&e);
+ sess_retry_free(r);
+ if (err)
+ *err = msg;
+ else
+ free(msg);
+ return -1;
+ }
+ }
+ /* SESS_STREAM_FAILED */
+ /* Context overflow is not a provider fault: the request grew past
+ * the window, so compact the history and retry once. One recovery
+ * per turn keeps a too-large tail (or a provider that reports a
+ * smaller window than it enforces) from looping the turn. */
+ dbgf("RUN overflow-retried=%d is_overflow=%d limit=%lu\n",(int)overflow_retried,(int)sess_error_is_context_overflow(&e),s->compact.context_limit);
+ if (!overflow_retried && s->compact.context_limit > 0 &&
+ sess_error_is_context_overflow(&e)) {
+ overflow_retried = true;
+ char *reason = e.message ? xstrdup(e.message)
+ : xstrdup("context overflow");
+ sess_error_clear(&e);
+ sess_emit ev;
+ memset(&ev, 0, sizeof ev);
+ ev.kind = SESS_EMIT_VERBATIM;
+ ev.text = xstrfmt(
+ "\n" DIM
+ "[request exceeds the context window: %s; compacting and "
+ "retrying]" RESET "\n",
+ reason);
+ if (cb)
+ cb(&ev, user);
+ sess_emit_free(&ev);
+ free(reason);
+ compact_report crep;
+ memset(&crep, 0, sizeof crep);
+ char *cerr2 = NULL;
+ int crr = session_compact_if_needed(s, true, &crep, &cerr2);
+ if (cerr2) {
+ free(cerr2);
+ crr = 0;
+ }
+ if (crr > 0)
+ session_print_compact(&crep);
+ /* Retry once even when the compact pass was a no-op: a provider
+ * that reports a smaller window than it enforces may still accept
+ * the resubmitted request. overflow_retried stops a second pass.
+ * e was cleared above; the fresh error (if any) is surfaced below. */
+ continue;
+ }
+ char *msg = xstrfmt("stream error: %s",
+ e.message ? e.message : "unknown");
+ sess_error_clear(&e);
+ if (err)
+ *err = msg;
+ else
+ free(msg);
+ return -1;
+ }
+}
+
+int session_run_turn(session *s, const char *prompt, std::atomic<bool> *stop,
+ sess_emit_cb cb, void *user, char **err, int *result) {
+ int rc = session_run_turn_impl(s, prompt, stop, cb, user, err, result);
+ /* --debug: refresh the history dump on every turn, whatever the outcome.
+ * A failed dump never fails the turn; it is reported on stderr. */
+ char *derr = NULL;
+ if (session_debug_dump(s, &derr) != 0) {
+ if (s && s->debug_path)
+ fprintf(stderr, "debug: %s\n", derr ? derr : "dump failed");
+ free(derr);
+ }
+ return rc;
+}
diff --git a/src/session.h b/src/session.h
new file mode 100644
index 0000000..ce4dedf
--- /dev/null
+++ b/src/session.h
@@ -0,0 +1,293 @@
+#ifndef LLSH_SESSION_H
+#define LLSH_SESSION_H
+
+#include <atomic>
+#include <stdlib.h>
+
+#include "config.h"
+#include "message.h"
+#include "compact.h"
+#include "goal.h"
+
+/* ------------------------------------------------------------------ */
+/* Model abstraction (mirrors the rig `Agent` a session drives). */
+/* A model is an opaque object with a small vtable: a single-shot */
+/* `complete` (summarizer / goal evaluator) and a `stream` that drains */
+/* one turn, emitting events as they arrive. */
+/* ------------------------------------------------------------------ */
+
+/* One unit the stream can emit, in arrival order. */
+enum {
+ SESS_EMIT_TEXT = 0,
+ SESS_EMIT_REASONING,
+ SESS_EMIT_VERBATIM,
+ SESS_EMIT_STATUS,
+ SESS_EMIT_RESULT,
+};
+
+typedef struct sess_emit {
+ int kind; /* one of SESS_EMIT_* */
+ char *text; /* TEXT/REASONING/VERBATIM/RESULT: body */
+ bool from_shell; /* RESULT: came from a shell-style tool */
+ unsigned long input_tokens; /* STATUS: the new context size */
+} sess_emit;
+
+void sess_emit_free(sess_emit *e);
+
+/* Callback invoked for each emit. Return 0 to continue, non-zero to abort
+ * (a user stop). */
+typedef int (*sess_emit_cb)(const sess_emit *e, void *user);
+
+/* Error reported by a model call. `message` is owned by the receiver and
+ * must be freed; the other fields are plain values. */
+typedef struct sess_error {
+ char *message;
+ int http_status; /* 0 = no HTTP status */
+ int transport; /* 1 = bare transport error (no status) */
+ unsigned long retry_after_secs; /* 0 = none */
+} sess_error;
+
+void sess_error_clear(sess_error *e);
+
+/* Terminal result of a model `stream` call. */
+enum {
+ SESS_STREAM_OK = 0,
+ SESS_STREAM_RETRYABLE, /* transient, safe to retry (no output committed) */
+ SESS_STREAM_FAILED, /* non-retryable failure */
+ SESS_STREAM_STOPPED, /* the user stopped the turn */
+};
+
+typedef struct llsh_model llsh_model;
+struct llsh_model {
+ /* Single-shot completion (summarizer / goal evaluator). Returns the
+ * model's text (caller frees) or NULL on error (`err` filled). */
+ char *(*complete)(const llsh_model *m, const char *prompt, sess_error *err);
+
+ /* Stream one turn: send history + prompt, call `cb` for each emit.
+ * `stop` is polled between emits; if set (or `cb` returns non-zero) the
+ * stream stops and returns SESS_STREAM_STOPPED.
+ * On SESS_STREAM_OK / SESS_STREAM_STOPPED:
+ * - *assistant_text (caller frees; may be empty) is the turn's prose;
+ * - *input_tokens (last completion call's input tokens, or 0) is set;
+ * - if the model produced structured history, *new_messages is an
+ * allocated array of owned `message` values and *n_new is its length;
+ * the caller takes ownership (release with `delete[]`).
+ * When no structured history is produced, leave *new_messages NULL and
+ * *n_new 0; the caller falls back to `assistant_text`.
+ * On SESS_STREAM_RETRYABLE / SESS_STREAM_FAILED fills *err. */
+ int (*stream)(const llsh_model *m,
+ const message *history, size_t nhistory,
+ const char *prompt,
+ sess_emit_cb cb, void *user,
+ std::atomic<bool> *stop,
+ char **assistant_text, unsigned long *input_tokens,
+ message **new_messages, size_t *n_new,
+ sess_error *err);
+
+ /* Optional destructor. When set, `llsh_model_free` calls it; otherwise
+ * it falls back to plain `free`. */
+ void (*destroy)(llsh_model *m);
+};
+
+static inline void llsh_model_free(llsh_model *m) {
+ if (!m)
+ return;
+ if (m->destroy)
+ m->destroy(m);
+ else
+ free(m);
+}
+
+/* ------------------------------------------------------------------ */
+/* Pure helpers (no session state; directly unit-testable). */
+/* ------------------------------------------------------------------ */
+
+/* HTTP statuses treated as transient (worth retrying). */
+bool sess_is_transient_status(int status);
+
+/* Exponential backoff for the `attempt`-th retry (1-indexed), in whole
+ * seconds: 1, 2, 4, 8, 16, then capped at 30. */
+unsigned long sess_backoff_secs(unsigned long attempt);
+
+/* Render a duration (whole seconds, at least 1) for a retry notice. */
+unsigned long sess_human_secs(unsigned long secs);
+
+/* Classification of a retryable provider failure. */
+typedef struct sess_retry {
+ char *reason; /* owned */
+ unsigned long retry_after_secs; /* 0 = none */
+ bool has_retry_after;
+} sess_retry;
+void sess_retry_free(sess_retry *r);
+
+/* Classify a model error as retryable. Returns a freshly allocated
+ * sess_retry, or NULL if the error is permanent. Caller frees. */
+sess_retry *sess_classify_retry(const sess_error *e);
+
+/* True when a model error means the request exceeded the provider's context
+ * window (a 400-class error mentioning context length, or a 413). The turn
+ * can recover by compacting history and retrying once. */
+bool sess_error_is_context_overflow(const sess_error *e);
+
+/* Map one streamed assistant content item to an emit.
+ * `kind`: "text" | "reasoning" | "tool_call".
+ * `text`: the body (text/reasoning) or, for a tool call, the tool name.
+ * `args`: for a tool call, the args JSON; else NULL.
+ * Returns 0 and fills *out (caller frees the fields), or 1 for no emit. */
+int sess_assistant_emit(const char *kind, const char *text, const char *args,
+ sess_emit *out);
+
+/* Render a tool's result for display: the full result body, normalized to
+ * end with exactly one newline. Returns 0 and fills *out (caller frees the
+ * fields), or 1 for no emit (NULL / empty result). */
+int sess_tool_result_emit(const char *tool, const char *result,
+ sess_emit *out);
+
+/* The models.json `default` preset name active at startup, or NULL.
+ * Caller frees. */
+char *session_active_preset_from(const dotagents *dot);
+
+/* The base system preamble: `.agents` system-prompt.md (a full override) or
+ * the configured preamble, with the project sections appended. Caller frees. */
+char *sess_base_preamble_for(const config *cfg, const dotagents *dot);
+
+/* The full preamble for a profile: the sub-agent's agent.md body plus the
+ * project sections, or the base preamble when the profile is inactive or its
+ * sub-agent is missing. Caller frees. `profile` may be NULL. */
+char *sess_preamble_for_profile(const dotagents *dot, const config *cfg,
+ const char *profile);
+
+/* Apply model/connection overrides onto `cfg`. In strict mode (a preset) an
+ * unparseable provider is a hard error; otherwise it is skipped. Returns 0 on
+ * success, -1 on error (*err set). */
+int sess_apply_overrides(config *cfg, const char *model, const char *provider,
+ const char *base_url, const char *api_key,
+ bool strict, char **err);
+
+/* The effective config for a profile: a deep copy of `cfg` with the
+ * profile's config.json overrides applied. Caller frees with config_free.
+ * `profile` may be NULL (returns a plain clone). */
+config sess_effective_for_profile(const config *cfg, const dotagents *dot,
+ const char *profile);
+
+/* ------------------------------------------------------------------ */
+/* Session */
+/* ------------------------------------------------------------------ */
+
+typedef struct session session;
+
+/* Turn result. */
+enum { SESS_TURN_COMPLETED = 0, SESS_TURN_STOPPED = 1 };
+
+/* Adopt `cfg`, `dot`, and `model` (the caller no longer owns them). */
+session *session_new_with_model(llsh_model *model, config cfg, dotagents dot);
+void session_free(session *s);
+
+/* Inject a tool-less summarizer / goal evaluator / rubric judge (adopted).
+ * Returns s. */
+session *session_with_summarizer(session *s, llsh_model *summarizer);
+session *session_with_goal_evaluator(session *s, llsh_model *evaluator);
+session *session_with_rubric(session *s, llsh_model *rubric);
+
+/* -- accessors (caller frees returned strings) -- */
+char *session_model(session *s);
+const config *session_config(const session *s);
+char *session_active_preset(session *s);
+char *session_profile_name(session *s);
+const dotagents *session_dot(const session *s); /* borrowed; do not free */
+/* Allowed built-in tools for the active profile (borrowed; do not free).
+ * An empty list means "all built-in tools allowed". */
+const strlist *session_allowed_tools(session *s);
+unsigned long session_context_tokens(session *s);
+unsigned long session_context_limit(session *s);
+const goal_report *session_goal_report(session *s);
+void session_set_goal_report(session *s, goal_report *r); /* adopts r */
+char *session_last_assistant_text(session *s);
+/* Both return borrowed state, valid until the next set_model / set_profile /
+ * refresh_dot / apply_preset call. */
+const char *session_current_preamble(session *s);
+const config *session_active_config(session *s);
+int session_refresh_context_limit(session *s); /* 1 if a provider-reported limit was adopted, 0 if none was learned */
+
+/* -- history -- */
+size_t session_history_len(session *s);
+const message *session_history_at(session *s, size_t i);
+void session_push_history(session *s, message m); /* copies m */
+unsigned long session_last_context_tokens(session *s);
+void session_set_last_context_tokens(session *s, unsigned long t);
+void session_clear_history(session *s);
+/* Remove the most recent user turn. Returns -1 when none, else the number of
+ * messages removed. */
+int session_undo_last_turn(session *s);
+size_t session_message_count(session *s);
+char *session_transcript_markdown(session *s);
+
+/* -- compaction -- */
+/* Run the two-tier compaction pass if the context is large enough (or always,
+ * when `force`). Returns 0 = no pass (report untouched), 1 = pass ran
+ * (report filled), -1 = error (*err set). */
+int session_compact_if_needed(session *s, bool force, compact_report *report,
+ char **err);
+void session_print_compact(const compact_report *report);
+
+/* -- rubric compaction (SelfCompact, arXiv:2606.23525) -- */
+/* Record one completed tool round. The provider calls this after each tool
+ * round; it feeds the rubric probe gates. */
+void session_note_tool_round(session *s);
+
+/* Model-invoked compaction (the `compact` tool): run the pass unconditionally.
+ * Returns 0 = no pass (history too short), 1 = pass ran (report filled),
+ * -1 = error (*err set). */
+int session_force_compact(session *s, compact_report *report, char **err);
+
+/* End-of-turn rubric probe: when the gates are open, ask the summarizer to
+ * judge the trajectory and compact only if the rubric fires. Returns
+ * 0 = no compaction, 1 = compacted, -1 = error (*err set). */
+int session_rubric_probe(session *s, char **err);
+
+/* -- goal -- */
+int session_evaluate_goal(session *s, const goal_spec *spec,
+ const char *last_reply, goal_verdict *out,
+ char **err);
+
+/* -- model / profile -- */
+int session_set_model(session *s, const char *name, char **err);
+int session_set_profile(session *s, const char *id, char **err);
+int session_refresh_dot(session *s, const char *workspace, const char *global,
+ char **err);
+
+/* -- debug dump -- */
+/* Set (or clear with NULL) the file the whole conversation history is
+ * written to when `--debug` is active. `path` is adopted. */
+void session_set_debug_path(session *s, char *path);
+/* Write the current history to the debug path (no-op when unset).
+ * Returns 0 on success / no-op, -1 on error (*err set, caller frees). */
+int session_debug_dump(session *s, char **err);
+
+/* -- save / load / resume -- */
+typedef struct session_snapshot {
+ unsigned long version;
+ char *cwd;
+ unsigned long long saved_at;
+ config cfg; /* owned (name avoids the C++ type/member clash) */
+ history hist; /* owned */
+ char *active_preset; /* may be NULL */
+ char *profile; /* may be NULL */
+ unsigned long last_context_tokens;
+} session_snapshot;
+
+void session_snapshot_free(session_snapshot *snap);
+session_snapshot *session_snapshot_of(session *s);
+int session_save(session *s, const char *path, char **err);
+session_snapshot *session_load(const char *path, char **err);
+/* Adopt `snap`, `dot`, and `model`; free the snapshot. */
+session *session_resume(session_snapshot *snap, dotagents dot,
+ llsh_model *model, char **err);
+
+/* Run one user turn. `stop` may be set by the caller/cb to abort; if already
+ * set the prompt is kept in context and the turn is Stopped. `err` and
+ * `result` are optional. Returns 0 on a completed/stopped turn, -1 on error. */
+int session_run_turn(session *s, const char *prompt, std::atomic<bool> *stop,
+ sess_emit_cb cb, void *user, char **err, int *result);
+
+#endif
diff --git a/src/shell.cpp b/src/shell.cpp
new file mode 100644
index 0000000..9b0bcae
--- /dev/null
+++ b/src/shell.cpp
@@ -0,0 +1,303 @@
+#define _XOPEN_SOURCE 600
+#include "shell.h"
+
+#include <errno.h>
+#include <pty.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+/* Fallback cap for in-memory PTY capture; matches the config
+ * `max_output_chars` default. The caller overrides with the effective value. */
+#define DEFAULT_SHELL_CAPTURE_CAP 16000UL
+
+/* Read the current terminal size (0x0 when not a TTY). */
+static struct winsize current_winsize(void) {
+ struct winsize ws = {0, 0, 0, 0};
+ (void)ioctl(STDIN_FILENO, TIOCGWINSZ, &ws);
+ return ws;
+}
+
+/* Reader thread: stream the shell's PTY output to stdout, collapsing CRLF to
+ * a single LF (the real terminal's ONLCR will re-expand it exactly once). */
+static void *shell_reader(void *arg) {
+ interactive_shell *s = (interactive_shell *) arg;
+ unsigned char buf[8192];
+ unsigned char out[16384];
+ size_t out_len = 0;
+ bool pending_cr = false;
+
+ for (;;) {
+ ssize_t n = read(s->reader_fd, buf, sizeof buf);
+ if (n <= 0)
+ break;
+ for (ssize_t i = 0; i < n; i++) {
+ unsigned char b = buf[i];
+ if (pending_cr) {
+ if (b == '\n') {
+ out[out_len++] = '\n';
+ } else {
+ if (out_len + 2 > sizeof out) {
+ write(STDOUT_FILENO, out, out_len);
+ out_len = 0;
+ }
+ out[out_len++] = '\r';
+ out[out_len++] = b;
+ }
+ pending_cr = false;
+ } else if (b == '\r') {
+ pending_cr = true;
+ } else {
+ out[out_len++] = b;
+ }
+ }
+ if (out_len) {
+ (void)write(STDOUT_FILENO, out, out_len);
+ pthread_mutex_lock(&s->out_mu);
+ if (s->outbuf.len < s->capture_cap)
+ xbuf_putn(&s->outbuf, (const char *)out, out_len);
+ pthread_mutex_unlock(&s->out_mu);
+ out_len = 0;
+ }
+ }
+ if (pending_cr)
+ out[out_len++] = '\r';
+ if (out_len) {
+ (void)write(STDOUT_FILENO, out, out_len);
+ pthread_mutex_lock(&s->out_mu);
+ if (s->outbuf.len < s->capture_cap)
+ xbuf_putn(&s->outbuf, (const char *)out, out_len);
+ pthread_mutex_unlock(&s->out_mu);
+ }
+ pthread_mutex_lock(&s->done_mu);
+ s->reader_done = true;
+ pthread_mutex_unlock(&s->done_mu);
+ return NULL;
+}
+
+interactive_shell *shell_spawn(void) {
+ const char *shell = getenv("SHELL");
+ if (!shell || !*shell)
+ shell = "bash";
+
+ char *argv[] = {(char *)shell, "-i", NULL};
+ struct winsize ws = current_winsize();
+
+ int master = 0;
+ pid_t pid = forkpty(&master, NULL, NULL, &ws);
+ if (pid < 0)
+ return NULL;
+ if (pid == 0) {
+ /* Child: reset inherited signal handlers, then exec. */
+ signal(SIGINT, SIG_DFL);
+ signal(SIGQUIT, SIG_DFL);
+ signal(SIGTERM, SIG_DFL);
+ execvp(shell, argv);
+ _exit(127);
+ }
+
+ interactive_shell *s = (interactive_shell *) xmalloc(sizeof *s);
+ memset(s, 0, sizeof *s);
+ s->pid = (int)pid;
+ s->master = master;
+ s->last_cwd = NULL;
+
+ int reader_fd = dup(master);
+ if (reader_fd < 0) {
+ close(master);
+ xbuf_free(&s->outbuf);
+ pthread_mutex_destroy(&s->out_mu);
+ free(s);
+ return NULL;
+ }
+ s->reader_fd = reader_fd;
+ s->reader_done = false;
+ xbuf_init(&s->outbuf);
+ pthread_mutex_init(&s->out_mu, NULL);
+ /* Cap the in-memory capture at the tool output limit (the config
+ * `max_output_chars` default); the caller overrides this with the
+ * effective config value. The transcript is trimmed to the same limit
+ * when recorded. */
+ s->capture_cap = DEFAULT_SHELL_CAPTURE_CAP;
+ if (pthread_mutex_init(&s->done_mu, NULL) != 0) {
+ close(reader_fd);
+ close(master);
+ free(s);
+ return NULL;
+ }
+ if (pthread_create(&s->reader_th, NULL, shell_reader, s) != 0) {
+ (void)kill((pid_t)pid, SIGKILL);
+ (void)kill(-(pid_t)pid, SIGKILL);
+ int status = 0;
+ (void)waitpid((pid_t)pid, &status, 0);
+ close(reader_fd);
+ close(master);
+ pthread_mutex_destroy(&s->done_mu);
+ xbuf_free(&s->outbuf);
+ pthread_mutex_destroy(&s->out_mu);
+ free(s);
+ return NULL;
+ }
+ return s;
+}
+
+int shell_write(const interactive_shell *s, const void *buf, size_t len) {
+ if (s->master < 0)
+ return -1;
+ const unsigned char *p = (const unsigned char* ) buf;
+ size_t off = 0;
+ while (off < len) {
+ ssize_t n = write(s->master, p + off, len - off);
+ if (n < 0) {
+ if (errno == EINTR)
+ continue;
+ return -1;
+ }
+ off += (size_t)n;
+ }
+ return 0;
+}
+
+void shell_update_winsize(const interactive_shell *s) {
+ if (s->master < 0)
+ return;
+ struct winsize ws = current_winsize();
+ (void)ioctl(s->master, TIOCSWINSZ, &ws);
+}
+
+/* Read a process's cwd from /proc/<pid>/cwd (Linux). Freshly allocated, or
+ * NULL when the process is gone / unreadable. */
+static char *read_proc_cwd(int pid) {
+ if (pid <= 0)
+ return NULL;
+ char path[64];
+ snprintf(path, sizeof path, "/proc/%d/cwd", pid);
+ char link[4096];
+ ssize_t n = readlink(path, link, sizeof link - 1);
+ if (n <= 0)
+ return NULL;
+ link[n] = '\0';
+ return xstrdup(link);
+}
+
+char *shell_final_cwd(const interactive_shell *s) {
+ char *live = read_proc_cwd(s->pid);
+ if (live)
+ return live;
+ if (s->last_cwd)
+ return xstrdup(s->last_cwd);
+ return NULL;
+}
+
+/* Strip terminal control/escape sequences from `src` so the text is suitable
+ * for the conversation context (no colors, cursor moves, OSC prompts, or
+ * prompt decorations). Keeps printable text, tabs, and newlines; a lone CR
+ * (not part of the already-collapsed CRLF) becomes a newline. */
+static char *shell_sanitize(const char *src) {
+ xbuf b;
+ xbuf_init(&b);
+ const unsigned char *p = (const unsigned char *)src;
+ while (*p) {
+ if (*p == '\x1B') {
+ if (p[1] == '[') {
+ /* CSI: params + intermediates, terminated by 0x40-0x7E. */
+ const unsigned char *q = p + 2;
+ while (*q && *q < 0x40) q++;
+ if (*q) q++;
+ p = q;
+ } else if (p[1] == ']') {
+ /* OSC: terminated by BEL (0x07) or ST (ESC \). */
+ const unsigned char *q = p + 2;
+ while (*q && *q != '\x07' &&
+ !(*q == '\x1B' && q[1] == '\\'))
+ q++;
+ if (*q == '\x07')
+ q++;
+ else if (*q == '\x1B')
+ q += 2;
+ p = q;
+ } else {
+ /* Other two/three-byte escapes (e.g. ESC ( B, ESC 7). */
+ p += (p[1] != 0) ? 2 : 1;
+ }
+ continue;
+ }
+ if (*p == '\r') {
+ xbuf_push(&b, '\n');
+ p++;
+ continue;
+ }
+ if (*p < 0x20 && *p != '\t' && *p != '\n') {
+ p++;
+ continue;
+ }
+ xbuf_push(&b, (char)*p);
+ p++;
+ }
+ return xbuf_steal(&b);
+}
+
+char *shell_captured(interactive_shell *s) {
+ pthread_mutex_lock(&s->out_mu);
+ const char *raw = s->outbuf.data ? s->outbuf.data : "";
+ char *out = shell_sanitize(raw);
+ xbuf_reset(&s->outbuf);
+ pthread_mutex_unlock(&s->out_mu);
+ return out;
+}
+
+bool shell_reap_if_dead(interactive_shell *s) {
+ if (s->pid == 0)
+ return true;
+ /* Wait for the PTY reader to see EOF before returning to the chat prompt,
+ * so final shell output is not interleaved with it. */
+ pthread_mutex_lock(&s->done_mu);
+ bool reader_done = s->reader_done;
+ pthread_mutex_unlock(&s->done_mu);
+ if (!reader_done) {
+ /* Track where the shell is while it is still alive: once it has
+ * exited, /proc/<pid>/cwd is unreadable even before the reap. */
+ char *cwd = read_proc_cwd(s->pid);
+ if (cwd) {
+ free(s->last_cwd);
+ s->last_cwd = cwd;
+ }
+ return false;
+ }
+ int status = 0;
+ pid_t r = waitpid((pid_t)s->pid, &status, WNOHANG);
+ if (r == (pid_t)s->pid) {
+ s->pid = 0;
+ return true;
+ }
+ return false;
+}
+
+void shell_free(interactive_shell *s) {
+ if (!s)
+ return;
+ if (s->pid != 0) {
+ /* Kill the shell (and its process group) so it does not print a
+ * newline or prompt fragment while we redraw the chat prompt. */
+ (void)kill((pid_t)s->pid, SIGKILL);
+ (void)kill(-(pid_t)s->pid, SIGKILL);
+ int status = 0;
+ (void)waitpid((pid_t)s->pid, &status, 0);
+ s->pid = 0;
+ }
+ if (s->master >= 0) {
+ close(s->master);
+ s->master = -1;
+ }
+ (void)pthread_join(s->reader_th, NULL);
+ pthread_mutex_destroy(&s->done_mu);
+ xbuf_free(&s->outbuf);
+ pthread_mutex_destroy(&s->out_mu);
+ free(s->last_cwd);
+ s->last_cwd = NULL;
+ free(s);
+}
diff --git a/src/shell.h b/src/shell.h
new file mode 100644
index 0000000..18c8f67
--- /dev/null
+++ b/src/shell.h
@@ -0,0 +1,49 @@
+#ifndef LLSH_SHELL_H
+#define LLSH_SHELL_H
+
+#include "util.h"
+#include <pthread.h>
+#include <stdbool.h>
+
+/* An interactive user shell running on a private PTY. llsh forwards raw
+ * terminal input to the PTY and streams the PTY's output back to the real
+ * terminal, so the shell's own line editor (Tab completion, history, …) is
+ * used while in shell mode. Mirrors the Rust `InteractiveShell` in main.rs.
+ * POSIX-only (forkpty/raw mode). */
+typedef struct interactive_shell {
+ int pid; /* shell PID; 0 once reaped */
+ int master; /* PTY master fd; -1 once closed */
+ int reader_fd; /* dup of master, owned by the reader thread */
+ pthread_t reader_th;
+ pthread_mutex_t done_mu; /* guards reader_done */
+ bool reader_done; /* set by the reader thread on EOF */
+ xbuf outbuf; /* owned; output capture (shared with reader thread) */
+ pthread_mutex_t out_mu; /* guards outbuf */
+ unsigned long capture_cap; /* max bytes to capture (bounds memory) */
+ char *last_cwd; /* owned; last observed cwd (fallback after reap) */
+} interactive_shell;
+
+/* Fork `$SHELL` (else `bash`) with `-i` on a private PTY and start a reader
+ * thread that streams the shell's output to stdout (CRLF collapsed to LF).
+ * Returns NULL on failure. */
+interactive_shell *shell_spawn(void);
+/* Forward raw input bytes to the shell. 0 on success, -1 on error. */
+int shell_write(const interactive_shell *s, const void *buf, size_t len);
+/* Resize the PTY to match the current terminal size. */
+void shell_update_winsize(const interactive_shell *s);
+/* True once the shell has exited, its output is drained, and it has been
+ * reaped. Cheap to call each loop iteration while in shell mode. */
+bool shell_reap_if_dead(interactive_shell *s);
+/* The directory the shell ended in: a live `/proc/<pid>/cwd` read while it
+ * is still alive, falling back to the last observed directory once reaped.
+ * Returns a freshly allocated string, or NULL when unknown. */
+char *shell_final_cwd(const interactive_shell *s);
+/* Everything the shell has printed so far (owned copy), and reset the
+ * capture. Thread-safe against the reader thread; returns an empty string
+ * when nothing was captured. */
+char *shell_captured(interactive_shell *s);
+/* Kill the shell (whole process group), close the PTY, and join the reader
+ * thread. Safe to call on a fully-reaped shell. */
+void shell_free(interactive_shell *s);
+
+#endif
diff --git a/src/tools.cpp b/src/tools.cpp
new file mode 100644
index 0000000..7656b23
--- /dev/null
+++ b/src/tools.cpp
@@ -0,0 +1,1341 @@
+#include "tools.h"
+#include "escape.h"
+
+#include <cjson/cJSON.h>
+#include <curl/curl.h>
+#include <ctype.h>
+#include <dirent.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <math.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include "limits.h"
+#include "llm.h"
+#include "verbose.h"
+
+/* Cap on bytes retained per output stream (mirrors tools.rs). */
+#define MAX_SHELL_OUTPUT_BYTES 1000000u
+#define MAX_MEMORY_HITS 10u
+#define MAX_MEMORY_BODY_CHARS 2000u
+#define MAX_TOOL_HTTP_BYTES (4u * 1024u * 1024u)
+
+typedef struct {
+ xbuf *buf;
+ size_t cap;
+} tool_http_write_ctx;
+
+static size_t tool_http_write(char *data, size_t size, size_t nmemb, void *ud) {
+ tool_http_write_ctx *ctx = (tool_http_write_ctx *)ud;
+ if (size && nmemb > SIZE_MAX / size)
+ return 0;
+ size_t n = size * nmemb;
+ if (ctx->buf->len > ctx->cap || n > ctx->cap - ctx->buf->len)
+ return 0;
+ xbuf_putn(ctx->buf, data, n);
+ return n;
+}
+
+/* ------------------------------------------------------------------ */
+/* Small helpers */
+/* ------------------------------------------------------------------ */
+
+/* ASCII lowercase copy (Rust to_lowercase for ASCII input). */
+static char *lower_dup(const char *s) {
+ xbuf b;
+ xbuf_init(&b);
+ for (const unsigned char *p = (const unsigned char *)s; *p; p++)
+ xbuf_push(&b, (char)((*p >= 'A' && *p <= 'Z') ? *p + 32 : *p));
+ return xbuf_steal(&b);
+}
+
+/* ------------------------------------------------------------------ */
+/* read_file */
+/* ------------------------------------------------------------------ */
+
+/* Total number of lines in `data` (a single trailing newline does not count
+ * as an extra line; an empty buffer is 0). */
+static unsigned long count_lines(const char *data, size_t len) {
+ if (len == 0)
+ return 0;
+ unsigned long nl = 0;
+ for (size_t i = 0; i < len; i++)
+ if (data[i] == '\n')
+ nl++;
+ return (data[len - 1] == '\n') ? nl : nl + 1;
+}
+
+/* Newly-allocated copy of the line range [start_line, start_line + max_lines)
+ * of `data`. start_line is 1-indexed (values < 1 mean "from the start");
+ * max_lines 0 means "no limit". A start_line past the last line yields "". */
+static char *slice_lines(const char *data, size_t len, unsigned long start_line,
+ unsigned long max_lines) {
+ const char *end = data + len;
+ const char *p = data;
+ unsigned long line = 1;
+ while (line < start_line) {
+ const char *nl = (const char* ) memchr(p, '\n', (size_t)(end - p));
+ if (!nl) {
+ p = end;
+ break; /* start_line is past the last line */
+ }
+ p = nl + 1;
+ line++;
+ }
+ const char *sel = p;
+ if (max_lines > 0) {
+ unsigned long taken = 0;
+ while (taken < max_lines && p < end) {
+ const char *nl = (const char* ) memchr(p, '\n', (size_t)(end - p));
+ if (!nl) {
+ p = end;
+ break;
+ }
+ p = nl + 1;
+ taken++;
+ }
+ }
+ size_t out_len = (size_t)(p - sel);
+ char *out = (char *) xmalloc(out_len + 1);
+ memcpy(out, sel, out_len);
+ out[out_len] = '\0';
+ return out;
+}
+
+char *tool_read_file(const char *path, unsigned long offset, unsigned long limit,
+ char **err) {
+ if (!path || !path[0]) {
+ if (err)
+ *err = xstrdup("missing `path` argument (expected a non-empty "
+ "string)");
+ return NULL;
+ }
+ verbose_log("tool_call",
+ (const vlog_kv[]){VLOG_STR("name", "read_file"),
+ VLOG_STR("path", path)},
+ 2);
+ const tool_limits *lim = tool_limits_current();
+ char *data = NULL;
+ size_t len = 0;
+ struct stat st;
+ if (stat(path, &st) == 0 && S_ISDIR(st.st_mode)) {
+ if (err)
+ *err = xstrfmt("failed to read `%s` as text (binary or unreadable?): "
+ "Is a directory (os error 21)", path);
+ return NULL;
+ }
+ size_t max_bytes = lim->max_file_bytes > SIZE_MAX
+ ? SIZE_MAX
+ : (size_t)lim->max_file_bytes;
+ char *read_err = NULL;
+ if (read_file_to_buf_limited(path, max_bytes, &data, &len, &read_err) != 0) {
+ if (err && read_err && xstreq(read_err, "file exceeds configured size limit"))
+ *err = xstrfmt("`%s` is too large to read (limit %llu bytes)",
+ path, lim->max_file_bytes);
+ else if (err)
+ *err = xstrfmt("failed to read `%s` as text (binary or unreadable?): %s",
+ path, read_err ? read_err : strerror(errno));
+ free(read_err);
+ return NULL;
+ }
+ free(read_err);
+ if (memchr(data, '\0', len) || !utf8_is_valid(data, len)) {
+ free(data);
+ if (err)
+ *err = xstrfmt("failed to read `%s` as text (binary or unreadable?): "
+ "stream did not contain valid UTF-8",
+ path);
+ return NULL;
+ }
+ /* Apply offset/limit: return only the requested line range. offset is a
+ * 1-indexed start line (0/1 = from the top); limit is a max line count
+ * (0 = all). Note how many lines remain when a limit truncated the file. */
+ if (offset > 0 || limit > 0) {
+ unsigned long start = (offset < 1) ? 1 : offset;
+ unsigned long total = count_lines(data, len); /* lines in the whole file */
+ char *sl = slice_lines(data, len, start, limit);
+ free(data);
+ if (limit > 0) {
+ unsigned long last_shown = start + limit - 1;
+ if (last_shown > total)
+ last_shown = total;
+ if (total > last_shown) {
+ unsigned long remaining = total - last_shown;
+ char *note = xstrfmt("%s\n[... %lu more %s; use offset=%lu to "
+ "continue ...]",
+ sl, remaining,
+ remaining == 1 ? "line" : "lines",
+ last_shown + 1);
+ free(sl);
+ sl = note;
+ }
+ }
+ data = sl;
+ }
+ verbose_log("tool_result",
+ (const vlog_kv[]){VLOG_STR("name", "read_file"),
+ VLOG_BOOL("ok", true)},
+ 2);
+ return data;
+}
+
+/* ------------------------------------------------------------------ */
+/* write_file */
+/* ------------------------------------------------------------------ */
+
+char *tool_write_file(const char *path, const char *content, char **err) {
+ if (!path || !path[0] || !content) {
+ if (err)
+ *err = xstrdup("write_file requires non-empty `path` and string `content`");
+ return NULL;
+ }
+ const char *slash = strrchr(path, '/');
+ if (slash && slash != path) {
+ char *parent = xstrndup(path, (size_t)(slash - path));
+ if (mkdir_p(parent) != 0) {
+ if (err)
+ *err = xstrfmt("creating parent dirs for `%s`: %s", path,
+ strerror(errno));
+ free(parent);
+ return NULL;
+ }
+ free(parent);
+ }
+ size_t clen = strlen(content);
+ if (write_file_from_buf(path, content, clen, NULL) != 0) {
+ if (err)
+ *err = xstrfmt("failed to write `%s`: %s", path, strerror(errno));
+ return NULL;
+ }
+ return xstrfmt("wrote %zu bytes to %s", clen, path);
+}
+
+/* ------------------------------------------------------------------ */
+/* run_shell */
+/* ------------------------------------------------------------------ */
+
+/* Lossy UTF-8: copy valid code points verbatim and replace each invalid
+ * byte with one U+FFFD, so the result is always well-formed UTF-8. */
+static char *utf8_lossy(const char *s, size_t len) {
+ xbuf b;
+ xbuf_init(&b);
+ const char *p = s;
+ const char *end = s + len;
+ while (p < end) {
+ int n = 0;
+ unsigned char c = (unsigned char)*p;
+ if (c < 0x80)
+ n = 1;
+ else if ((c & 0xE0) == 0xC0 && c >= 0xC2 && end - p >= 2 && (p[1] & 0xC0) == 0x80)
+ n = 2;
+ else if ((c & 0xF0) == 0xE0 && end - p >= 3 && (p[1] & 0xC0) == 0x80 &&
+ (p[2] & 0xC0) == 0x80 && !(c == 0xE0 && (unsigned char)p[1] < 0xA0) &&
+ !(c == 0xED && (unsigned char)p[1] >= 0xA0))
+ n = 3;
+ else if ((c & 0xF8) == 0xF0 && end - p >= 4 && (p[1] & 0xC0) == 0x80 &&
+ (p[2] & 0xC0) == 0x80 && (p[3] & 0xC0) == 0x80 &&
+ c <= 0xF4 && !(c == 0xF0 && (unsigned char)p[1] < 0x90) &&
+ !(c == 0xF4 && (unsigned char)p[1] >= 0x90))
+ n = 4;
+ if (n > 0) {
+ xbuf_putn(&b, p, (size_t)n);
+ p += (size_t)n;
+ } else {
+ xbuf_put(&b, "\xEF\xBF\xBD");
+ p++;
+ }
+ }
+ return xbuf_steal(&b);
+}
+
+static bool drain_fd(int fd, xbuf *b, size_t cap, bool *eof) {
+ char tmp[8192];
+ for (;;) {
+ /* Always keep draining so a child writing more than `cap` does not
+ * block on a full pipe; bytes past the cap are read and discarded. */
+ ssize_t r = read(fd, tmp, sizeof tmp);
+ if (r > 0) {
+ if (b->len < cap) {
+ size_t take = (size_t)r;
+ if (take > cap - b->len)
+ take = cap - b->len;
+ xbuf_putn(b, tmp, take);
+ }
+ continue;
+ }
+ if (r == 0) {
+ *eof = true;
+ } else if (errno == EAGAIN || errno == EWOULDBLOCK) {
+ *eof = false;
+ } else {
+ *eof = true; /* treat I/O errors as end-of-stream */
+ }
+ return *eof;
+ }
+}
+
+static void set_nonblock(int fd) {
+ int fl = fcntl(fd, F_GETFL, 0);
+ if (fl >= 0)
+ fcntl(fd, F_SETFL, fl | O_NONBLOCK);
+}
+
+char *tool_run_shell_timed(const char *command, unsigned long timeout_secs,
+ char **err) {
+ const char *label = command;
+ int out_fd[2] = {-1, -1};
+ int err_fd[2] = {-1, -1};
+ if (pipe(out_fd) != 0 || pipe(err_fd) != 0) {
+ close(out_fd[0]);
+ close(out_fd[1]);
+ close(err_fd[0]);
+ close(err_fd[1]);
+ if (err)
+ *err = xstrfmt("failed to run `%s`: %s (os error %d)", label,
+ strerror(errno), errno);
+ return NULL;
+ }
+ pid_t pid = fork();
+ if (pid < 0) {
+ close(out_fd[0]);
+ close(out_fd[1]);
+ close(err_fd[0]);
+ close(err_fd[1]);
+ if (err)
+ *err = xstrfmt("failed to run `%s`: %s (os error %d)", label,
+ strerror(errno), errno);
+ return NULL;
+ }
+ if (pid == 0) {
+ /* Own process group so a timeout kills the whole tree. */
+ if (setpgid(0, 0) != 0)
+ _exit(125);
+ if (dup2(out_fd[1], 1) < 0)
+ _exit(126);
+ if (dup2(err_fd[1], 2) < 0)
+ _exit(126);
+ int dn = open("/dev/null", O_RDONLY);
+ if (dn >= 0) {
+ if (dup2(dn, 0) < 0)
+ _exit(126);
+ close(dn);
+ }
+ char *argv[] = {"sh", "-c", (char *)command, NULL};
+ execvp("sh", argv);
+ _exit(127);
+ }
+ close(out_fd[1]);
+ close(err_fd[1]);
+ int out_r = out_fd[0];
+ int err_r = err_fd[0];
+ set_nonblock(out_r);
+ set_nonblock(err_r);
+ xbuf ob;
+ xbuf eb;
+ xbuf_init(&ob);
+ xbuf_init(&eb);
+ bool out_eof = false;
+ bool err_eof = false;
+ int status = 0;
+ bool done = false;
+ bool timed_out = false;
+ double deadline = now_secs() + (double)timeout_secs;
+ while (!done) {
+ pid_t r = waitpid(pid, &status, WNOHANG);
+ if (r == pid) {
+ done = true;
+ break;
+ }
+ if (r < 0 && errno == ECHILD) {
+ done = true;
+ break;
+ }
+ if (now_secs() >= deadline) {
+ kill(-pid, SIGKILL);
+ for (;;) {
+ r = waitpid(pid, &status, WNOHANG);
+ if (r == pid)
+ break;
+ sleep_ms(20);
+ }
+ timed_out = true;
+ break;
+ }
+ if (!out_eof)
+ drain_fd(out_r, &ob, MAX_SHELL_OUTPUT_BYTES, &out_eof);
+ if (!err_eof)
+ drain_fd(err_r, &eb, MAX_SHELL_OUTPUT_BYTES, &err_eof);
+ sleep_ms(20);
+ }
+ if (!timed_out) {
+ /* Bounded final drain: pick up buffered data after the child exits
+ * without spinning forever if a grandchild keeps the pipe open. */
+ double drain_deadline = now_secs() + 2.0;
+ while (!out_eof && now_secs() < drain_deadline) {
+ drain_fd(out_r, &ob, MAX_SHELL_OUTPUT_BYTES, &out_eof);
+ if (!out_eof)
+ sleep_ms(5);
+ }
+ while (!err_eof && now_secs() < drain_deadline) {
+ drain_fd(err_r, &eb, MAX_SHELL_OUTPUT_BYTES, &err_eof);
+ if (!err_eof)
+ sleep_ms(5);
+ }
+ }
+ close(out_r);
+ close(err_r);
+ char *stdout_s = utf8_lossy(ob.data ? ob.data : "", ob.len);
+ char *stderr_s = utf8_lossy(eb.data ? eb.data : "", eb.len);
+ xbuf_free(&ob);
+ xbuf_free(&eb);
+ if (timed_out) {
+ free(stdout_s);
+ free(stderr_s);
+ if (err)
+ *err = xstrfmt("command timed out after %lus: %s",
+ timeout_secs, label);
+ return NULL;
+ }
+ char *so = xstr_rtrim_dup(stdout_s);
+ char *se = xstr_rtrim_dup(stderr_s);
+ free(stdout_s);
+ free(stderr_s);
+ xbuf t;
+ xbuf_init(&t);
+ if (so[0])
+ xbuf_put(&t, so);
+ if (se[0]) {
+ if (t.len)
+ xbuf_push(&t, '\n');
+ xbuf_put(&t, "[stderr]\n");
+ xbuf_put(&t, se);
+ }
+ if (t.len == 0)
+ xbuf_put(&t, "(no output)");
+ if (WIFEXITED(status)) {
+ int code = WEXITSTATUS(status);
+ if (code != 0)
+ xbuf_fmt(&t, "\n[exit code %d]", code);
+ } else if (WIFSIGNALED(status)) {
+ xbuf_put(&t, "\n[terminated by signal]");
+ }
+ char *text = xbuf_steal(&t);
+ char *out = tool_truncate_output(text);
+ free(text);
+ free(so);
+ free(se);
+ return out;
+}
+
+char *tool_run_shell(const char *command, char **err) {
+ if (!command || !command[0]) {
+ if (err)
+ *err = xstrdup("run_shell requires a non-empty `command`");
+ return NULL;
+ }
+ const tool_limits *lim = tool_limits_current();
+ if (shell_gate(command, lim, err) != 0)
+ return NULL;
+ verbose_log("tool_call",
+ (const vlog_kv[]){VLOG_STR("name", "run_shell"),
+ VLOG_STR("command", command)},
+ 2);
+ unsigned long timeout = lim->shell_timeout_secs;
+ char *out = tool_run_shell_timed(command, timeout, err);
+ verbose_log("tool_result",
+ (const vlog_kv[]){VLOG_STR("name", "run_shell"),
+ VLOG_BOOL("ok", out != NULL)},
+ 2);
+ return out;
+}
+
+char *tool_truncate_output(const char *text) {
+ const tool_limits *lim = tool_limits_current();
+ unsigned long limit = lim->max_output_chars > 0 ? lim->max_output_chars : 1;
+ size_t len = strlen(text);
+ if (len <= limit)
+ return xstrdup(text);
+ size_t cut = utf8_byte_len(text, limit);
+ size_t max_printf_precision = (size_t)(~0u >> 1);
+ if (cut > max_printf_precision)
+ cut = max_printf_precision;
+ char *out = xstrfmt("%.*s", (int)cut, text);
+ char *res = xstrfmt("%s\u2026\n[output truncated at %lu chars]", out,
+ limit);
+ free(out);
+ return res;
+}
+
+/* ------------------------------------------------------------------ */
+/* use_skill */
+/* ------------------------------------------------------------------ */
+
+char *tool_use_skill(const dotagents *dot, const char *id, char **err) {
+ char *out = NULL;
+ const skill *sk = dot ? dotagents_find_skill(dot, id) : NULL;
+ if (sk) {
+ if (sk->enabled) {
+ char *body = xstr_rtrim_dup(sk->body ? sk->body : "");
+ out = xstrfmt("%s\n\n%s", sk->name, body);
+ free(body);
+ } else {
+ if (err)
+ *err = xstrfmt("skill `%s` is disabled (enabled: false)", id);
+ }
+ } else {
+ if (!dot || dot->skill_count == 0) {
+ if (err)
+ *err = xstrdup(
+ "no skills defined (expected .agents/skills/<id>/skill.md)");
+ } else {
+ xbuf ids;
+ xbuf_init(&ids);
+ for (size_t i = 0; i < dot->skill_count; i++) {
+ if (i)
+ xbuf_put(&ids, ", ");
+ xbuf_put(&ids, dot->skills[i].id);
+ }
+ if (err)
+ *err = xstrfmt("no skill `%s`; available: %s", id,
+ ids.data ? ids.data : "");
+ xbuf_free(&ids);
+ }
+ }
+ return out;
+}
+
+/* ------------------------------------------------------------------ */
+/* search_memories */
+/* ------------------------------------------------------------------ */
+
+void tool_tokenize(const char *query, strlist *out) {
+ char *low = lower_dup(query);
+ size_t n = strlen(low);
+ size_t start = 0;
+ for (size_t i = 0; i <= n; i++) {
+ char c = i < n ? low[i] : '\0';
+ bool alnum = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
+ if (alnum) {
+ continue;
+ }
+ if (i > start)
+ sl_push(out, xstrndup(low + start, i - start));
+ start = i + 1;
+ }
+ free(low);
+}
+
+bool tool_is_subsequence(const char *needle, const char *haystack) {
+ const char *h = haystack;
+ for (const char *n = needle; *n; n++) {
+ const char *found = NULL;
+ while (*h) {
+ if (*h == *n) {
+ found = h;
+ break;
+ }
+ h++;
+ }
+ if (!found)
+ return false;
+ h = found + 1;
+ }
+ return true;
+}
+
+double tool_field_match_strength(const char *field, const char *term) {
+ if (!field[0] || !term[0])
+ return 0.0;
+ if (strcmp(field, term) == 0)
+ return 1.0;
+ size_t start = 0;
+ for (size_t i = 0;; i++) {
+ char c = field[i];
+ bool alnum = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
+ if (alnum)
+ continue;
+ if (i > start) {
+ char *tok = xstrndup(field + start, i - start);
+ bool eq = xstreq(tok, term);
+ free(tok);
+ if (eq)
+ return 0.9;
+ }
+ start = i + 1;
+ if (!c)
+ break;
+ }
+ if (strstr(field, term))
+ return 0.7;
+ if (strlen(term) >= 3 && tool_is_subsequence(term, field))
+ return 0.4;
+ return 0.0;
+}
+
+double tool_memory_relevance(const memory *m, const strlist *terms) {
+ if (terms->len == 0)
+ return 0.0;
+ char *title = lower_dup(m->title ? m->title : "");
+ xbuf tagsb;
+ xbuf_init(&tagsb);
+ for (size_t i = 0; i < m->tag_count; i++) {
+ if (i)
+ xbuf_put(&tagsb, " ");
+ xbuf_put(&tagsb, m->tags[i]);
+ }
+ char *tags_raw = xbuf_steal(&tagsb);
+ char *tags = lower_dup(tags_raw);
+ free(tags_raw);
+ char *summary = lower_dup(m->summary ? m->summary : "");
+ char *body = lower_dup(m->body ? m->body : "");
+ const char *fields[4] = {title, tags, summary, body};
+ const double weights[4] = {4.0, 3.0, 2.0, 1.0};
+ double points = 0.0;
+ size_t matched = 0;
+ for (size_t t = 0; t < terms->len; t++) {
+ double best = 0.0;
+ for (int f = 0; f < 4; f++) {
+ double s = weights[f] * tool_field_match_strength(fields[f],
+ terms->items[t]);
+ if (s > best)
+ best = s;
+ }
+ if (best > 0.0) {
+ matched++;
+ points += best;
+ }
+ }
+ free(title);
+ free(tags);
+ free(summary);
+ free(body);
+ if (matched == 0)
+ return 0.0;
+ double coverage = (double)matched / (double)terms->len;
+ return points * (1.0 + coverage);
+}
+
+typedef struct {
+ double score;
+ const memory *m;
+} scored;
+
+static int cmp_scored(const void *a, const void *b) {
+ double sa = ((const scored *)a)->score;
+ double sb = ((const scored *)b)->score;
+ if (sa != sb)
+ return sa < sb ? 1 : -1;
+ int ra = memory_importance_rank(((const scored *)a)->m);
+ int rb = memory_importance_rank(((const scored *)b)->m);
+ if (ra != rb)
+ return ra - rb;
+ return strcmp(((const scored *)a)->m->id, ((const scored *)b)->m->id);
+}
+
+char *tool_search_memories(const dotagents *dot, const char *query,
+ char **err) {
+ char *needle = xstr_trim_dup(query);
+ if (!needle[0]) {
+ if (err)
+ *err = xstrdup("query must not be empty");
+ free(needle);
+ return NULL;
+ }
+ char *out = NULL;
+ if (!dot || dot->memory_count == 0) {
+ out = xstrdup("no memories defined (expected .agents/memories/*.md)");
+ free(needle);
+ return out;
+ }
+ {
+ strlist terms;
+ sl_init(&terms);
+ tool_tokenize(needle, &terms);
+ scored *arr = (scored *) xmalloc(dot->memory_count * sizeof(scored));
+ size_t n = 0;
+ for (size_t i = 0; i < dot->memory_count; i++) {
+ double s = tool_memory_relevance(&dot->memories[i], &terms);
+ if (s > 0.0)
+ arr[n++] = (scored){s, &dot->memories[i]};
+ }
+ sl_free(&terms);
+ if (n == 0) {
+ out = xstrfmt("no memories matching `%s`", needle);
+ } else {
+ qsort(arr, n, sizeof(scored), cmp_scored);
+ xbuf b;
+ xbuf_init(&b);
+ size_t shown = n < MAX_MEMORY_HITS ? n : MAX_MEMORY_HITS;
+ for (size_t i = 0; i < shown; i++) {
+ const memory *m = arr[i].m;
+ xbuf_fmt(&b, "## %s [importance: %s]\n", m->title,
+ m->importance ? m->importance : "");
+ if (m->tag_count > 0) {
+ xbuf tags;
+ xbuf_init(&tags);
+ for (size_t j = 0; j < m->tag_count; j++) {
+ if (j)
+ xbuf_put(&tags, ", ");
+ xbuf_put(&tags, m->tags[j]);
+ }
+ xbuf_fmt(&b, "tags: %s\n", tags.data ? tags.data : "");
+ xbuf_free(&tags);
+ }
+ if (m->summary && m->summary[0])
+ xbuf_fmt(&b, "%s (summary)\n", m->summary);
+ char *body = xstr_trim_dup(m->body ? m->body : "");
+ if (body[0]) {
+ size_t blen = strlen(body);
+ size_t cut = utf8_byte_len(body, MAX_MEMORY_BODY_CHARS);
+ xbuf_putn(&b, body, cut);
+ if (cut < blen)
+ xbuf_put(&b, "\xE2\x80\xA6");
+ xbuf_put(&b, "\n");
+ }
+ free(body);
+ xbuf_put(&b, "\n");
+ }
+ if (n > MAX_MEMORY_HITS)
+ xbuf_fmt(&b, "\xE2\x80\xA6"
+ "and %zu more matches\n",
+ n - MAX_MEMORY_HITS);
+ out = xbuf_steal(&b);
+ }
+ free(arr);
+ free(needle);
+ }
+ return out;
+}
+
+/* ------------------------------------------------------------------ */
+/* Embedding tool */
+/* ------------------------------------------------------------------ */
+
+void embed_target_free(embed_target *t) {
+ free(t->model);
+ free(t->base_url);
+ free(t->api_key);
+ t->model = t->base_url = t->api_key = NULL;
+}
+
+int tool_embed_resolve(const config *cfg, const dotagents *dot,
+ embed_target *out, char **err) {
+ memset(out, 0, sizeof *out);
+ if (err)
+ *err = NULL;
+ if (!dot || !dot->has_models) {
+ if (err)
+ *err = xstrdup("embeddings not configured: add a preset to "
+ ".agents/models.json");
+ return -1;
+ }
+ const char *name = dot->models.default_name;
+ if (!name || !*name) {
+ if (err)
+ *err = xstrdup("embeddings not configured: no `default` preset "
+ "in .agents/models.json");
+ return -1;
+ }
+ const model_preset *p =
+ (dot && dot->has_models) ? models_file_get(&dot->models, name) : NULL;
+ if (!p) {
+ if (err)
+ *err = xstrfmt("embeddings preset `%s` not found in "
+ ".agents/models.json",
+ name);
+ return -1;
+ }
+ out->model = (p->model && p->model[0]) ? xstrdup(p->model)
+ : (cfg->model ? xstrdup(cfg->model)
+ : NULL);
+ out->base_url = (p->base_url && p->base_url[0])
+ ? xstrdup(p->base_url)
+ : (cfg->base_url ? xstrdup(cfg->base_url) : NULL);
+ out->api_key = (p->api_key && p->api_key[0])
+ ? xstrdup(p->api_key)
+ : (cfg->api_key ? xstrdup(cfg->api_key) : NULL);
+ if (!out->model || !out->model[0] || !out->api_key || !out->api_key[0]) {
+ char *m = xstrfmt("embeddings preset `%s` needs a `model` and an "
+ "`api_key`",
+ name);
+ embed_target_free(out);
+ if (err)
+ *err = m;
+ else
+ free(m);
+ return -1;
+ }
+ return 0;
+}
+
+char *tool_embed_request(const char *model, const char *text, char **err) {
+ if (err)
+ *err = NULL;
+ if (!model || !model[0]) {
+ if (err)
+ *err = xstrdup("embeddings: empty model name");
+ return NULL;
+ }
+ if (!text || !text[0]) {
+ if (err)
+ *err = xstrdup("embed: empty text");
+ return NULL;
+ }
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "model", model);
+ cJSON_AddStringToObject(o, "input", text);
+ char *s = cJSON_PrintUnformatted(o);
+ cJSON_Delete(o);
+ return s;
+}
+
+char *tool_embed_url(const char *base_url) {
+ const char *base =
+ base_url && base_url[0] ? base_url : "https://api.openai.com/v1";
+ size_t n = strlen(base);
+ while (n && base[n - 1] == '/')
+ n--;
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_putn(&b, base, n);
+ xbuf_put(&b, "/embeddings");
+ char *out = xbuf_steal(&b);
+ xbuf_free(&b);
+ return out;
+}
+
+int tool_embed_parse(const char *body, char **vector_json, char **err) {
+ if (vector_json)
+ *vector_json = NULL;
+ if (err)
+ *err = NULL;
+ if (!body || !body[0]) {
+ if (err)
+ *err = xstrdup("embeddings: empty response body");
+ return -1;
+ }
+ cJSON *o = cJSON_Parse(body);
+ if (!o) {
+ if (err)
+ *err = xstrdup("embeddings: invalid JSON in response");
+ return -1;
+ }
+ const cJSON *data = cJSON_GetObjectItemCaseSensitive(o, "data");
+ const cJSON *first =
+ (data && cJSON_IsArray(data) && cJSON_GetArraySize(data) > 0)
+ ? cJSON_GetArrayItem(data, 0)
+ : NULL;
+ const cJSON *emb = first
+ ? cJSON_GetObjectItemCaseSensitive(first, "embedding")
+ : NULL;
+ if (!emb || !cJSON_IsArray(emb) || cJSON_GetArraySize(emb) == 0) {
+ if (err)
+ *err = xstrdup("embeddings: no `data[0].embedding` array in "
+ "response");
+ cJSON_Delete(o);
+ return -1;
+ }
+ const cJSON *v = NULL;
+ cJSON_ArrayForEach(v, emb) {
+ if (!cJSON_IsNumber(v) || !isfinite(v->valuedouble)) {
+ if (err)
+ *err = xstrdup("embeddings: non-numeric element in vector");
+ cJSON_Delete(o);
+ return -1;
+ }
+ }
+ cJSON *arr = cJSON_Duplicate(emb, 1);
+ cJSON_Delete(o);
+ if (!arr) {
+ if (err)
+ *err = xstrdup("embeddings: out of memory");
+ return -1;
+ }
+ char *s = cJSON_PrintUnformatted(arr);
+ cJSON_Delete(arr);
+ if (vector_json)
+ *vector_json = s;
+ else
+ free(s);
+ return s ? 0 : -1;
+}
+
+/* One-shot JSON POST (mirrors agent_http_get). Returns 0 + *body owned
+ * (may be empty), or -1 with *err owned. */
+static int embed_post(const char *url, const char *api_key, const char *req,
+ long *status, char **body, char **err) {
+ if (body)
+ *body = NULL;
+ if (err)
+ *err = NULL;
+ CURL *c = curl_easy_init();
+ if (!c) {
+ if (err)
+ *err = xstrdup("could not initialize curl");
+ return -1;
+ }
+ xbuf b;
+ xbuf_init(&b);
+ struct curl_slist *h = NULL;
+ if (api_key && api_key[0]) {
+ xbuf hb;
+ xbuf_init(&hb);
+ xbuf_fmt(&hb, "Authorization: Bearer %s", api_key);
+ h = curl_slist_append(h, hb.data);
+ xbuf_free(&hb);
+ }
+ h = curl_slist_append(h, "Content-Type: application/json");
+ curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
+ curl_easy_setopt(c, CURLOPT_URL, url);
+ curl_easy_setopt(c, CURLOPT_POST, 1L);
+ curl_easy_setopt(c, CURLOPT_POSTFIELDS, req);
+ curl_easy_setopt(c, CURLOPT_TIMEOUT, 30L);
+ curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
+ const char *ca = llsh_ca_bundle();
+ if (ca)
+ curl_easy_setopt(c, CURLOPT_CAINFO, ca);
+ tool_http_write_ctx write_ctx = {&b, MAX_TOOL_HTTP_BYTES};
+ curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, tool_http_write);
+ curl_easy_setopt(c, CURLOPT_WRITEDATA, &write_ctx);
+ CURLcode rc = curl_easy_perform(c);
+ curl_slist_free_all(h);
+ if (rc != CURLE_OK) {
+ char *e = xstrdup(curl_easy_strerror(rc));
+ free(b.data);
+ curl_easy_cleanup(c);
+ if (err)
+ *err = e;
+ return -1;
+ }
+ long code = 0;
+ curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &code);
+ if (status)
+ *status = code;
+ if (body)
+ *body = xbuf_steal(&b);
+ else
+ free(b.data);
+ curl_easy_cleanup(c);
+ return 0;
+}
+
+char *tool_embed(const char *text, const config *cfg, const dotagents *dot,
+ char **err) {
+ if (err)
+ *err = NULL;
+ embed_target t;
+ if (tool_embed_resolve(cfg, dot, &t, err) != 0)
+ return NULL;
+ char *req = tool_embed_request(t.model, text, NULL);
+ char *url = tool_embed_url(t.base_url);
+ long status = 0;
+ char *body = NULL;
+ int rc = embed_post(url, t.api_key, req, &status, &body, err);
+ free(req);
+ free(url);
+ if (rc != 0) {
+ free(body);
+ return NULL;
+ }
+ char *vec = NULL;
+ if (status < 200 || status >= 300 || tool_embed_parse(body, &vec, err) != 0) {
+ if (!err || !*err) {
+ char *m = xstrfmt("embeddings request failed (HTTP %ld): %s",
+ status, body ? body : "(no body)");
+ if (err)
+ *err = m;
+ else
+ free(m);
+ }
+ free(body);
+ embed_target_free(&t);
+ return NULL;
+ }
+ free(body);
+ cJSON *v = cJSON_Parse(vec);
+ int dims = v ? cJSON_GetArraySize(v) : 0;
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_fmt(&b, "{\"dimensions\":%d,\"vector\":%s}", dims, vec);
+ char *out = xbuf_steal(&b);
+ xbuf_free(&b);
+ free(vec);
+ embed_target_free(&t);
+ if (v)
+ cJSON_Delete(v);
+ return out;
+}
+
+/* ------------------------------------------------------------------ */
+/* llm (one-shot completion / embed) */
+/* ------------------------------------------------------------------ */
+
+char *tool_llm(const llm_tool_call *call, const config *cfg,
+ const dotagents *dot, char **err) {
+ if (err)
+ *err = NULL;
+ if (!call || !call->prompt || !call->prompt[0]) {
+ if (err)
+ *err = xstrdup("llm: missing required `prompt`");
+ return NULL;
+ }
+ if (call->embed)
+ return tool_embed(call->prompt, cfg, dot, err);
+
+ if (call->response_schema && call->response_schema[0]) {
+ cJSON *s = cJSON_Parse(call->response_schema);
+ if (!s) {
+ if (err)
+ *err = xstrdup("llm: `response_schema` is not valid JSON");
+ return NULL;
+ }
+ cJSON_Delete(s);
+ }
+
+ llm_conn conn;
+ switch (cfg->provider) {
+ case PROV_ANTHROPIC:
+ conn.provider = LLM_ANTHROPIC;
+ break;
+ case PROV_OLLAMA:
+ conn.provider = LLM_OLLAMA;
+ break;
+ default:
+ conn.provider = LLM_OPENAI;
+ break;
+ }
+ conn.model =
+ (call->model && call->model[0]) ? call->model : cfg->model;
+ if (!conn.model || !conn.model[0]) {
+ if (err)
+ *err = xstrdup("llm: no model configured");
+ return NULL;
+ }
+ conn.base_url = cfg->base_url;
+ conn.api_key = cfg->api_key;
+ conn.ca_bundle = llsh_ca_bundle();
+
+ llm_params p;
+ llm_params_init(&p);
+ p.temperature = call->temperature;
+ p.max_tokens = call->max_tokens;
+ p.reasoning_effort = call->reasoning_effort;
+ p.thinking_budget = call->thinking_budget;
+ p.response_schema = call->response_schema;
+
+ llm_part part;
+ memset(&part, 0, sizeof part);
+ part.kind = LLM_TEXT;
+ part.text = call->prompt;
+ llm_message one;
+ one.role = LLM_ROLE_USER;
+ one.parts = &part;
+ one.part_count = 1;
+
+ char *req = llm_build_request_ex(&conn, NULL, &one, 1, NULL, false, &p);
+ char *url = llm_url(&conn);
+ long status = 0;
+ char *body = NULL, *headers = NULL, *herr = NULL;
+ int hrc = llm_post(&conn, url, req, &status, &body, &headers, &herr,
+ NULL, NULL);
+ free(req);
+ free(url);
+ if (hrc != 0) {
+ if (err)
+ *err = herr ? herr : xstrdup("llm: transport error");
+ free(body);
+ free(headers);
+ return NULL;
+ }
+ char *result = NULL;
+ llm_completion c;
+ memset(&c, 0, sizeof c);
+ if (llm_parse_body(&conn, body, &c) == 0) {
+ result = (c.text && c.text[0]) ? c.text : xstrdup("");
+ c.text = NULL;
+ if (c.reasoning && c.reasoning[0]) {
+ char *full = xstrfmt("[reasoning]\n%s\n\n[answer]\n%s",
+ c.reasoning, result);
+ free(result);
+ result = full;
+ }
+ c.reasoning = NULL;
+ llm_completion_free(&c);
+ }
+ if (status < 200 || status >= 300 || !result) {
+ char *em = llm_parse_error(body ? body : "", status);
+ if (err)
+ *err = em;
+ free(result);
+ result = NULL;
+ }
+ free(body);
+ free(headers);
+ return result;
+}
+
+/* ------------------------------------------------------------------ */
+/* Display vtable */
+/* ------------------------------------------------------------------ */
+
+/* Owned copy of a string argument from an invocation's args JSON, or
+ * NULL when the JSON is missing / not an object / the key is absent or
+ * not a string. */
+static char *display_arg(const char *args_json, const char *key) {
+ if (!args_json || !args_json[0])
+ return NULL;
+ cJSON *a = cJSON_Parse(args_json);
+ if (!a)
+ return NULL;
+ char *out = NULL;
+ const cJSON *v = cJSON_GetObjectItem(a, key);
+ if (cJSON_IsString(v) && v->valuestring)
+ out = xstrdup(v->valuestring);
+ cJSON_Delete(a);
+ return out;
+}
+
+/* run_shell: present the invocation as a plain shell line, `$ <command>`
+ * (prompt in the accent color, the command muted like other tool args).
+ * Returns NULL (generic fallback) when no command string is present. */
+static char *display_run_shell(const char *args_json) {
+ char *cmd = display_arg(args_json, "command");
+ if (!cmd || !cmd[0]) {
+ free(cmd);
+ return NULL;
+ }
+ char *line = xstrfmt("\n" ESC_COLOR_GREEN "$ " ESC_RESET ESC_DIM "%s"
+ ESC_RESET "\n",
+ cmd);
+ free(cmd);
+ return line;
+}
+
+/* ------------------------------------------------------------------ */
+/* Registry -- the single source of truth for the built-in tools */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ const char *name;
+ const char *wire; /* description sent to the model (schema) */
+ const char *display; /* one-liner for /tools */
+ const char *preamble; /* one-liner for the default system preamble */
+ tool_render_call render_call; /* transcript display hook (or NULL) */
+ bool dim_result; /* result rendered muted (shell-style) */
+} tool_def;
+
+static const tool_def TOOL_DEFS[] = {
+ {"read_file",
+ "Read a file and return its text content. Optional `offset` (1-indexed "
+ "start line) and `limit` (max lines) read a line range; a trailing note "
+ "reports how many lines remain.",
+ "Read a file's contents, optionally a line range via `offset`/`limit`.",
+ "read a file's contents (optionally a line range)",
+ NULL, true},
+ {"write_file",
+ "Write content to a path, creating parent directories as needed. "
+ "Overwrites the file if it exists.",
+ "Create or overwrite a file, creating parent dirs.",
+ "create or overwrite a file",
+ NULL, false},
+ {"run_shell",
+ "Run a shell command via sh -c and return stdout/stderr and a "
+ "status note on failure. Use it for listing and searching too "
+ "(ls, rg, grep, find).",
+ "Run a shell command via sh -c (120s timeout, kills the process "
+ "tree).",
+ "run a shell command (`sh -c`) in the working directory",
+ display_run_shell, true},
+ {"use_skill",
+ "Load the full instructions of a .agents skill by its id.",
+ "Load a .agents skill's full instructions by id.",
+ "load a .agents skill's full instructions by id",
+ NULL, false},
+ {"search_memories",
+ "Search .agents memories by keyword or tag (ranked).",
+ "Search .agents memories (ranked, fuzzy) by keyword or tag.",
+ "search .agents memories by keyword or tag (ranked)",
+ NULL, false},
+ {"embed",
+ "Embed a text into a vector via the configured embeddings endpoint. "
+ "Returns `{\"dimensions\":N,\"vector\":[...]}`. Use it for semantic "
+ "comparison: embed a query and candidates, then rank by cosine "
+ "similarity.",
+ "Embed text into a vector (configured `embedding` profile).",
+ "embed text into a vector for semantic comparison",
+ NULL, true},
+ {"llm",
+ "One-shot LLM call: send `prompt` as a single user message to the "
+ "session's model and return the reply. Optional generation "
+ "parameters: `model` override, `temperature` (0..2), `max_tokens`, "
+ "`reasoning_effort` (low/medium/high), `thinking_budget` (tokens), "
+ "and `response_schema` (a JSON Schema the reply must match; the "
+ "model is instructed to answer with one conforming JSON object). "
+ "`embed: true` embeds the prompt instead and returns "
+ "`{\"dimensions\":N,\"vector\":[...]}`.",
+ "One-shot LLM call (JSON schema, reasoning params, or embed mode).",
+ "make a one-shot LLM call (JSON schema, reasoning params, embed mode)",
+ NULL, false},
+ {"compact",
+ "Compress the conversation context now: summarize the earlier turns "
+ "into a compact summary and re-inject recent file reads. Use it when "
+ "the context is large and mostly settled, or before a long new task.",
+ "Summarize earlier context to free tokens (model-invoked compaction).",
+ "compact the conversation context (summarize the earlier turns)",
+ NULL, true},
+};
+
+size_t tool_builtin_count(void) {
+ return sizeof TOOL_DEFS / sizeof TOOL_DEFS[0];
+}
+
+const char *tool_builtin_name(size_t i) {
+ return i < tool_builtin_count() ? TOOL_DEFS[i].name : NULL;
+}
+
+const tool_display *tool_display_for(const char *name) {
+ if (!name)
+ return NULL;
+ static tool_display table[sizeof TOOL_DEFS / sizeof TOOL_DEFS[0]];
+ static bool built = false;
+ if (!built) {
+ for (size_t i = 0; i < tool_builtin_count(); i++) {
+ table[i].name = TOOL_DEFS[i].name;
+ table[i].render_call = TOOL_DEFS[i].render_call;
+ table[i].dim_result = TOOL_DEFS[i].dim_result;
+ }
+ built = true;
+ }
+ for (size_t i = 0; i < tool_builtin_count(); i++)
+ if (xstreq(table[i].name, name))
+ return &table[i];
+ return NULL;
+}
+
+const char *tool_desc(const char *name) {
+ for (size_t i = 0; i < tool_builtin_count(); i++)
+ if (xstreq(TOOL_DEFS[i].name, name))
+ return TOOL_DEFS[i].wire;
+ return "";
+}
+
+/* The JSON schema for a built-in tool's parameters (fresh object). */
+cJSON *tool_params_json(const char *name) {
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "type", "object");
+ cJSON *properties = cJSON_AddObjectToObject(o, "properties");
+ cJSON *required = cJSON_AddArrayToObject(o, "required");
+ if (xstreq(name, "read_file")) {
+ cJSON *p = cJSON_AddObjectToObject(properties, "path");
+ cJSON_AddStringToObject(p, "type", "string");
+ cJSON_AddStringToObject(p, "description",
+ "Path to read (relative to the working directory, or absolute).");
+ cJSON *offset = cJSON_AddObjectToObject(properties, "offset");
+ cJSON_AddStringToObject(offset, "type", "integer");
+ cJSON_AddNumberToObject(offset, "minimum", 1);
+ cJSON_AddStringToObject(offset, "description",
+ "Optional 1-indexed line to start from (defaults to the first line).");
+ cJSON *limit = cJSON_AddObjectToObject(properties, "limit");
+ cJSON_AddStringToObject(limit, "type", "integer");
+ cJSON_AddNumberToObject(limit, "minimum", 1);
+ cJSON_AddStringToObject(limit, "description",
+ "Optional maximum number of lines to read (defaults to the whole file).");
+ cJSON_AddItemToArray(required, cJSON_CreateString("path"));
+ } else if (xstreq(name, "write_file")) {
+ cJSON_AddStringToObject(cJSON_AddObjectToObject(properties, "path"),
+ "type", "string");
+ cJSON_AddStringToObject(cJSON_AddObjectToObject(properties, "content"),
+ "type", "string");
+ cJSON_AddItemToArray(required, cJSON_CreateString("path"));
+ cJSON_AddItemToArray(required, cJSON_CreateString("content"));
+ } else if (xstreq(name, "run_shell")) {
+ cJSON_AddStringToObject(cJSON_AddObjectToObject(properties, "command"),
+ "type", "string");
+ cJSON_AddItemToArray(required, cJSON_CreateString("command"));
+ } else if (xstreq(name, "use_skill")) {
+ cJSON_AddStringToObject(cJSON_AddObjectToObject(properties, "id"),
+ "type", "string");
+ cJSON_AddItemToArray(required, cJSON_CreateString("id"));
+ } else if (xstreq(name, "search_memories")) {
+ cJSON_AddStringToObject(cJSON_AddObjectToObject(properties, "query"),
+ "type", "string");
+ cJSON_AddItemToArray(required, cJSON_CreateString("query"));
+ } else if (xstreq(name, "embed")) {
+ cJSON *p = cJSON_AddObjectToObject(properties, "text");
+ cJSON_AddStringToObject(p, "type", "string");
+ cJSON_AddStringToObject(p, "description",
+ "The text to embed (a sentence, paragraph, or document excerpt).");
+ cJSON_AddItemToArray(required, cJSON_CreateString("text"));
+ } else if (xstreq(name, "llm")) {
+ cJSON *p = cJSON_AddObjectToObject(properties, "prompt");
+ cJSON_AddStringToObject(p, "type", "string");
+ cJSON_AddStringToObject(p, "description", "The user message to send to the model in the one-shot call.");
+ p = cJSON_AddObjectToObject(properties, "model");
+ cJSON_AddStringToObject(p, "type", "string");
+ cJSON_AddStringToObject(p, "description", "Optional model name override (defaults to the session model).");
+ p = cJSON_AddObjectToObject(properties, "temperature");
+ cJSON_AddStringToObject(p, "type", "number");
+ cJSON_AddNumberToObject(p, "minimum", 0);
+ cJSON_AddNumberToObject(p, "maximum", 2);
+ cJSON_AddStringToObject(p, "description", "Optional sampling temperature (0..2).");
+ p = cJSON_AddObjectToObject(properties, "max_tokens");
+ cJSON_AddStringToObject(p, "type", "integer");
+ cJSON_AddNumberToObject(p, "minimum", 1);
+ cJSON_AddStringToObject(p, "description", "Optional cap on generated tokens.");
+ p = cJSON_AddObjectToObject(properties, "reasoning_effort");
+ cJSON_AddStringToObject(p, "type", "string");
+ cJSON *effort = cJSON_CreateArray();
+ cJSON_AddItemToArray(effort, cJSON_CreateString("low"));
+ cJSON_AddItemToArray(effort, cJSON_CreateString("medium"));
+ cJSON_AddItemToArray(effort, cJSON_CreateString("high"));
+ cJSON_AddItemToObject(p, "enum", effort);
+ cJSON_AddStringToObject(p, "description", "Optional reasoning effort: low, medium, or high.");
+ p = cJSON_AddObjectToObject(properties, "thinking_budget");
+ cJSON_AddStringToObject(p, "type", "integer");
+ cJSON_AddNumberToObject(p, "minimum", 1);
+ cJSON_AddStringToObject(p, "description", "Optional extended-thinking budget in tokens.");
+ p = cJSON_AddObjectToObject(properties, "response_schema");
+ cJSON_AddStringToObject(p, "type", "object");
+ cJSON_AddStringToObject(p, "description", "Optional JSON Schema the reply must match; the model answers with a single conforming JSON object.");
+ p = cJSON_AddObjectToObject(properties, "embed");
+ cJSON_AddStringToObject(p, "type", "boolean");
+ cJSON_AddStringToObject(p, "description", "Embed the prompt instead of calling the model (returns a vector).");
+ cJSON_AddItemToArray(required, cJSON_CreateString("prompt"));
+ }
+ return o;
+}
+
+/* The "Available tools:" section of the default system preamble. */
+char *tool_preamble_list(void) {
+ xbuf b;
+ xbuf_init(&b);
+ xbuf_put(&b, "Available tools:\n");
+ for (size_t i = 0; i < tool_builtin_count(); i++)
+ xbuf_fmt(&b, "- %s: %s\n", TOOL_DEFS[i].name, TOOL_DEFS[i].preamble);
+ char *out = xbuf_steal(&b);
+ xbuf_free(&b);
+ return out;
+}
+
+size_t tool_list_entries(const tool_entry **out) {
+ static tool_entry table[sizeof TOOL_DEFS / sizeof TOOL_DEFS[0]];
+ static bool built = false;
+ if (!built) {
+ for (size_t i = 0; i < tool_builtin_count(); i++) {
+ table[i].name = TOOL_DEFS[i].name;
+ table[i].desc = TOOL_DEFS[i].display;
+ }
+ built = true;
+ }
+ *out = table;
+ return tool_builtin_count();
+}
diff --git a/src/tools.h b/src/tools.h
new file mode 100644
index 0000000..9423320
--- /dev/null
+++ b/src/tools.h
@@ -0,0 +1,118 @@
+#ifndef LLSH_TOOLS_H
+#define LLSH_TOOLS_H
+
+#include <cjson/cJSON.h>
+
+#include "config.h"
+#include "util.h"
+#include "dotagents.h"
+
+/* Mirrors tools.rs: the built-in tools the agent can call.
+ * Each tool returns an owned string on success, or NULL with an owned
+ * error message in **err (caller frees whichever was returned). */
+
+char *tool_read_file(const char *path, unsigned long offset, unsigned long limit,
+ char **err);
+char *tool_write_file(const char *path, const char *content, char **err);
+char *tool_run_shell(const char *command, char **err);
+char *tool_run_shell_timed(const char *command, unsigned long timeout_secs,
+ char **err);
+/* `dot` is the caller's loaded .agents snapshot (borrowed); NULL means "no
+ * skills/memories". */
+char *tool_use_skill(const dotagents *dot, const char *id, char **err);
+char *tool_search_memories(const dotagents *dot, const char *query, char **err);
+
+/* Embedding tool. The `default` preset of `.agents/models.json` supplies
+ * the connection; preset fields fall back to the top-level values. */
+typedef struct {
+ char *model; /* owned */
+ char *base_url; /* owned */
+ char *api_key; /* owned */
+} embed_target;
+void embed_target_free(embed_target *t);
+/* Resolve the configured embedding connection. 0 + *out filled, or -1 with
+ * *err owned. */
+int tool_embed_resolve(const config *cfg, const dotagents *dot,
+ embed_target *out, char **err);
+
+/* Pure helpers (no network; unit-testable). */
+char *tool_embed_request(const char *model, const char *text, char **err);
+char *tool_embed_url(const char *base_url);
+/* `body` -> the `data[0].embedding` array as a JSON array string. 0 +
+ * *vector_json owned, or -1 with *err owned. */
+int tool_embed_parse(const char *body, char **vector_json, char **err);
+
+/* Full tool: resolve + POST {base}/embeddings + parse. Returns owned
+ * `{"dimensions":N,"vector":[...]}` or NULL with *err owned. */
+char *tool_embed(const char *text, const config *cfg, const dotagents *dot,
+ char **err);
+
+/* LLM call tool: a one-shot completion through the session's provider
+ * (OpenAI-compatible, Anthropic, or Ollama). `prompt` is the single user
+ * message. The remaining fields are optional generation parameters, each
+ * with an "unset" value (temperature < 0, max_tokens / thinking_budget 0,
+ * pointers NULL); `embed` true routes the prompt to the embeddings
+ * endpoint instead. All strings borrowed for the call's lifetime. */
+typedef struct {
+ const char *prompt; /* required */
+ const char *model; /* optional override; NULL = session model */
+ double temperature; /* < 0 = unset */
+ unsigned long max_tokens; /* 0 = unset */
+ const char *reasoning_effort; /* "low" / "medium" / "high"; NULL = unset */
+ unsigned long thinking_budget; /* 0 = unset (tokens) */
+ const char *response_schema; /* JSON Schema text; NULL = unset */
+ bool embed; /* true = embed the prompt instead */
+} llm_tool_call;
+
+/* Full tool: one-shot completion (or embed when `call->embed`). Returns
+ * owned result text, or NULL with *err owned. */
+char *tool_llm(const llm_tool_call *call, const config *cfg, const dotagents *dot,
+ char **err);
+
+/* Pure helpers (mirroring the tested functions in tools.rs). */
+char *tool_truncate_output(const char *text);
+void tool_tokenize(const char *query, strlist *out);
+bool tool_is_subsequence(const char *needle, const char *haystack);
+double tool_field_match_strength(const char *field, const char *term);
+double tool_memory_relevance(const memory *m, const strlist *terms);
+
+/* Static registry for /tools display. Returns the entry count and sets
+ * *out to a static array valid for the process lifetime. */
+typedef struct {
+ const char *name;
+ const char *desc;
+} tool_entry;
+size_t tool_list_entries(const tool_entry **out);
+
+/* ------------------------------------------------------------------ */
+/* Display vtable: per-tool transcript rendering */
+/* ------------------------------------------------------------------ */
+/*
+ * Presentation hooks for the built-in tools. A tool may supply a
+ * `render_call` that renders the one-line transcript display for an
+ * invocation from its raw args JSON (freshly allocated; return NULL to
+ * fall back to the generic `# name <args>` line). Tools without an
+ * entry (e.g. MCP tools) always use the generic line. `dim_result`
+ * marks the tool's result as shell-style (the UI renders it muted).
+ */
+typedef char *(*tool_render_call)(const char *args_json);
+
+typedef struct {
+ const char *name;
+ tool_render_call render_call; /* NULL -> generic display */
+ bool dim_result;
+} tool_display;
+
+/* The vtable entry for a built-in tool (borrowed; valid for the
+ * process lifetime), or NULL when `name` is not a built-in tool. */
+const tool_display *tool_display_for(const char *name);
+
+/* Built-in tool registry (single source of truth; llm.h builds the
+ * provider tool schemas and the default preamble from it). */
+size_t tool_builtin_count(void);
+const char *tool_builtin_name(size_t i);
+const char *tool_desc(const char *name); /* schema description; "" if unknown */
+cJSON *tool_params_json(const char *name); /* fresh object; caller frees */
+char *tool_preamble_list(void); /* "Available tools:" section; caller frees */
+
+#endif
diff --git a/src/util.cpp b/src/util.cpp
new file mode 100644
index 0000000..4721e11
--- /dev/null
+++ b/src/util.cpp
@@ -0,0 +1,628 @@
+#include "util.h"
+
+#include <ctype.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <stdarg.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <time.h>
+#include <unistd.h>
+
+/* ------------------------------------------------------------------ */
+/* Allocation */
+/* ------------------------------------------------------------------ */
+
+void *xmalloc(size_t n) {
+ void *p = malloc(n ? n : 1);
+ if (!p) {
+ fprintf(stderr, "llsh: out of memory\n");
+ abort();
+ }
+ return p;
+}
+
+void *xrealloc(void *p, size_t n) {
+ void *q = realloc(p, n ? n : 1);
+ if (!q) {
+ fprintf(stderr, "llsh: out of memory\n");
+ abort();
+ }
+ return q;
+}
+
+/* ------------------------------------------------------------------ */
+/* xbuf */
+/* ------------------------------------------------------------------ */
+
+void xbuf_init(xbuf *b) {
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+void xbuf_free(xbuf *b) {
+ free(b->data);
+ b->data = NULL;
+ b->len = b->cap = 0;
+}
+
+void xbuf_reset(xbuf *b) {
+ b->len = 0;
+}
+
+static void xbuf_reserve(xbuf *b, size_t extra) {
+ if (b->len + extra + 1 > b->cap) {
+ size_t cap = b->cap ? b->cap : 64;
+ while (cap < b->len + extra + 1)
+ cap *= 2;
+ b->data = (char *) xrealloc(b->data, cap);
+ b->cap = cap;
+ }
+}
+
+void xbuf_putn(xbuf *b, const char *s, size_t n) {
+ xbuf_reserve(b, n);
+ memcpy(b->data + b->len, s, n);
+ b->len += n;
+ b->data[b->len] = '\0';
+}
+
+void xbuf_put(xbuf *b, const char *s) {
+ if (s)
+ xbuf_putn(b, s, strlen(s));
+}
+
+void xbuf_push(xbuf *b, char c) {
+ xbuf_reserve(b, 1);
+ b->data[b->len++] = c;
+ b->data[b->len] = '\0';
+}
+
+int xbuf_fmt(xbuf *b, const char *fmt, ...) {
+ va_list ap, ap2;
+ if (!fmt)
+ return 0;
+ va_start(ap, fmt);
+ va_copy(ap2, ap);
+ int n = vsnprintf(NULL, 0, fmt, ap2);
+ va_end(ap2);
+ if (n < 0) {
+ va_end(ap);
+ return -1;
+ }
+ xbuf_reserve(b, (size_t)n);
+ vsnprintf(b->data + b->len, (size_t)n + 1, fmt, ap);
+ va_end(ap);
+ b->len += (size_t)n;
+ return n;
+}
+
+char *xbuf_steal(xbuf *b) {
+ if (!b->data) {
+ b->data = (char *) xmalloc(1);
+ }
+ b->data[b->len] = '\0';
+ char *s = b->data;
+ b->data = NULL;
+ b->len = b->cap = 0;
+ return s;
+}
+
+size_t xbuf_curl_write(char *data, size_t sz, size_t nm, void *ud) {
+ xbuf_putn((xbuf *)ud, data, sz * nm);
+ return sz * nm;
+}
+
+/* ------------------------------------------------------------------ */
+/* Strings */
+/* ------------------------------------------------------------------ */
+
+char *xstrdup(const char *s) {
+ size_t n = s ? strlen(s) : 0;
+ char *p = (char *) xmalloc(n + 1);
+ if (s)
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+char *xstrndup(const char *s, size_t n) {
+ char *p = (char *) xmalloc(n + 1);
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+int xstreq(const char *a, const char *b) {
+ return a && b && strcmp(a, b) == 0;
+}
+
+const char *xtrim_span(const char *s, size_t n, size_t *out_len) {
+ size_t a = 0, b = n;
+ while (a < b && isascii((unsigned char)s[a]) && isspace((unsigned char)s[a]))
+ a++;
+ while (b > a && isascii((unsigned char)s[b - 1]) &&
+ isspace((unsigned char)s[b - 1]))
+ b--;
+ *out_len = b - a;
+ return s + a;
+}
+
+bool utf8_is_ws(uint32_t c) {
+ return c == 0x09 || c == 0x0A || c == 0x0B || c == 0x0C || c == 0x0D ||
+ c == 0x20 || c == 0x85 || c == 0xA0 || c == 0x1680 ||
+ (c >= 0x2000 && c <= 0x200A) || c == 0x2028 || c == 0x2029 ||
+ c == 0x202F || c == 0x205F || c == 0x3000;
+}
+
+static uint32_t decode_utf8(const char *p, const char *end, size_t *out_len);
+
+char *xstr_ltrim_dup(const char *s) {
+ if (!s) return xstrdup("");
+ size_t len = strlen(s);
+ const char *p = s, *end = s + len;
+ while (p < end) {
+ size_t l;
+ uint32_t cp = decode_utf8(p, end, &l);
+ if (!utf8_is_ws(cp))
+ break;
+ p += l;
+ }
+ return xstrndup(p, end - p);
+}
+
+char *xstr_rtrim_dup(const char *s) {
+ if (!s) return xstrdup("");
+ size_t len = strlen(s);
+ const char *p = s, *end = s + len, *last = s;
+ while (p < end) {
+ size_t l;
+ uint32_t cp = decode_utf8(p, end, &l);
+ if (!utf8_is_ws(cp))
+ last = p + l;
+ p += l;
+ }
+ return xstrndup(s, last - s);
+}
+
+char *xstr_trim_dup(const char *s) {
+ if (!s) return xstrdup("");
+ size_t len = strlen(s);
+ const char *p = s, *end = s + len;
+ while (p < end) {
+ size_t l;
+ uint32_t cp = decode_utf8(p, end, &l);
+ if (!utf8_is_ws(cp))
+ break;
+ p += l;
+ }
+ if (p == end)
+ return xstrdup("");
+ const char *start = p;
+ const char *last = p;
+ while (p < end) {
+ size_t l;
+ uint32_t cp = decode_utf8(p, end, &l);
+ if (!utf8_is_ws(cp))
+ last = p + l;
+ p += l;
+ }
+ return xstrndup(start, (size_t)(last - start));
+}
+
+char *xstrjoin2(const char *a, const char *b) {
+ xbuf b_;
+ xbuf_init(&b_);
+ xbuf_put(&b_, a);
+ xbuf_put(&b_, b);
+ return xbuf_steal(&b_);
+}
+
+char *xvsnprintf(const char *fmt, va_list ap) {
+ va_list ap2;
+ va_copy(ap2, ap);
+ int n = vsnprintf(NULL, 0, fmt, ap2);
+ va_end(ap2);
+ if (n < 0)
+ return xstrdup("");
+ char *p = (char *) xmalloc((size_t)n + 1);
+ vsnprintf(p, (size_t)n + 1, fmt, ap);
+ return p;
+}
+
+char *xstrfmt(const char *fmt, ...) {
+ va_list ap;
+ va_start(ap, fmt);
+ char *s = xvsnprintf(fmt, ap);
+ va_end(ap);
+ return s;
+}
+
+/* ------------------------------------------------------------------ */
+/* UTF-8 */
+/* ------------------------------------------------------------------ */
+
+static uint32_t decode_utf8(const char *p, const char *end, size_t *out_len) {
+ unsigned char c = (unsigned char)p[0];
+ if (c < 0x80) {
+ *out_len = 1;
+ return c;
+ }
+ if (end - p >= 2 && (c & 0xE0) == 0xC0 && (p[1] & 0xC0) == 0x80) {
+ *out_len = 2;
+ return ((uint32_t)(c & 0x1F) << 6) | (p[1] & 0x3F);
+ }
+ if (end - p >= 3 && (c & 0xF0) == 0xE0 && (p[1] & 0xC0) == 0x80 &&
+ (p[2] & 0xC0) == 0x80) {
+ *out_len = 3;
+ return ((uint32_t)(c & 0x0F) << 12) | ((uint32_t)(p[1] & 0x3F) << 6) |
+ (p[2] & 0x3F);
+ }
+ if (end - p >= 4 && (c & 0xF8) == 0xF0 && (p[1] & 0xC0) == 0x80 &&
+ (p[2] & 0xC0) == 0x80 && (p[3] & 0xC0) == 0x80) {
+ *out_len = 4;
+ return ((uint32_t)(c & 0x07) << 18) |
+ ((uint32_t)(p[1] & 0x3F) << 12) |
+ ((uint32_t)(p[2] & 0x3F) << 6) | (p[3] & 0x3F);
+ }
+ /* invalid: consume one byte */
+ *out_len = 1;
+ return 0xFFFD;
+}
+
+size_t utf8_len(const char *s) {
+ size_t n = 0;
+ const char *p = s, *end = s + strlen(s);
+ while (p < end) {
+ size_t l;
+ decode_utf8(p, end, &l);
+ p += l;
+ n++;
+ }
+ return n;
+}
+
+size_t utf8_byte_len(const char *s, size_t max_points) {
+ size_t n = 0;
+ const char *p = s, *end = s + strlen(s);
+ while (p < end && n < max_points) {
+ size_t l;
+ decode_utf8(p, end, &l);
+ p += l;
+ n++;
+ }
+ return (size_t)(p - s);
+}
+
+const char *utf8_next(const char *p, const char *end, size_t *cp) {
+ size_t l;
+ uint32_t c = decode_utf8(p, end, &l);
+ if (cp)
+ *cp = c;
+ return p + l;
+}
+
+static int is_wide(uint32_t c) {
+ return (c >= 0x1100 && c <= 0x115F) ||
+ (c >= 0x2E80 && c <= 0x303E) ||
+ (c >= 0x3041 && c <= 0x33FF) ||
+ (c >= 0x3400 && c <= 0x4DBF) ||
+ (c >= 0x4E00 && c <= 0x9FFF) ||
+ (c >= 0xA000 && c <= 0xA4CF) ||
+ (c >= 0xAC00 && c <= 0xD7A3) ||
+ (c >= 0xF900 && c <= 0xFAFF) ||
+ (c >= 0xFE30 && c <= 0xFE4F) ||
+ (c >= 0xFF00 && c <= 0xFF60) ||
+ (c >= 0xFFE0 && c <= 0xFFE6) ||
+ (c >= 0x1F300 && c <= 0x1F64F) ||
+ (c >= 0x1F900 && c <= 0x1F9FF) ||
+ (c >= 0x20000 && c <= 0x3FFFD);
+}
+
+size_t utf8_display_width(const char *s) {
+ size_t w = 0;
+ const char *p = s, *end = s + strlen(s);
+ while (p < end) {
+ size_t l;
+ uint32_t c = decode_utf8(p, end, &l);
+ if (c >= 0x200B && c <= 0x200F) { /* zero-width joiners etc. */
+ p += l;
+ continue;
+ }
+ if (c == 0) {
+ p += l;
+ continue;
+ }
+ w += is_wide(c) ? 2 : 1;
+ p += l;
+ }
+ return w;
+}
+
+bool utf8_is_valid(const char *s, size_t len) {
+ const unsigned char *p = (const unsigned char *)s;
+ const unsigned char *end = p + len;
+ while (p < end) {
+ unsigned char c = *p;
+ int n;
+ if (c < 0x80) {
+ n = 1;
+ } else if ((c & 0xE0) == 0xC0) {
+ n = 2;
+ } else if ((c & 0xF0) == 0xE0) {
+ n = 3;
+ } else if ((c & 0xF8) == 0xF0) {
+ n = 4;
+ } else {
+ return false;
+ }
+ if ((size_t)(end - p) < (size_t)n)
+ return false;
+ for (int i = 1; i < n; i++)
+ if ((p[i] & 0xC0) != 0x80)
+ return false;
+ if ((n == 2 && c < 0xC2) ||
+ (n == 3 && ((c == 0xE0 && p[1] < 0xA0) ||
+ (c == 0xED && p[1] >= 0xA0))) ||
+ (n == 4 && (c > 0xF4 ||
+ (c == 0xF0 && p[1] < 0x90) ||
+ (c == 0xF4 && p[1] >= 0x90))))
+ return false;
+ p += (size_t)n;
+ }
+ return true;
+}
+
+/* ------------------------------------------------------------------ */
+/* strlist */
+/* ------------------------------------------------------------------ */
+
+void sl_init(strlist *l) {
+ l->items = NULL;
+ l->len = 0;
+ l->cap = 0;
+}
+
+void sl_free(strlist *l) {
+ for (size_t i = 0; i < l->len; i++)
+ free(l->items[i]);
+ free(l->items);
+ sl_init(l);
+}
+
+void sl_push(strlist *l, char *owned) {
+ if (l->len + 1 > l->cap) {
+ l->cap = l->cap ? l->cap * 2 : 8;
+ l->items = (char* * ) xrealloc(l->items, l->cap * sizeof(char *));
+ }
+ l->items[l->len++] = owned;
+}
+
+void sl_push_copy(strlist *l, const char *s) {
+ sl_push(l, xstrdup(s));
+}
+
+char *sl_pop_front(strlist *l) {
+ if (l->len == 0)
+ return NULL;
+ char *p = l->items[0];
+ memmove(l->items, l->items + 1, (l->len - 1) * sizeof(*l->items));
+ l->len--;
+ return p;
+}
+
+/* ------------------------------------------------------------------ */
+/* Files */
+/* ------------------------------------------------------------------ */
+
+int read_file_to_buf(const char *path, char **out, size_t *out_len,
+ char **err) {
+ FILE *f = fopen(path, "rb");
+ if (!f) {
+ if (err)
+ *err = xstrdup(strerror(errno));
+ return -1;
+ }
+ xbuf b;
+ xbuf_init(&b);
+ char chunk[8192];
+ size_t r;
+ while ((r = fread(chunk, 1, sizeof chunk, f)) > 0)
+ xbuf_putn(&b, chunk, r);
+ if (ferror(f)) {
+ fclose(f);
+ xbuf_free(&b);
+ if (err)
+ *err = xstrdup("read error");
+ return -1;
+ }
+ fclose(f);
+ size_t total = b.len;
+ if (out)
+ *out = xbuf_steal(&b);
+ if (out_len)
+ *out_len = total;
+ return 0;
+}
+
+int read_file_to_buf_limited(const char *path, size_t max_bytes, char **out,
+ size_t *out_len, char **err) {
+ if (out)
+ *out = NULL;
+ if (err)
+ *err = NULL;
+ int fd = open(path, O_RDONLY | O_CLOEXEC);
+ if (fd < 0) {
+ if (err)
+ *err = xstrdup(strerror(errno));
+ return -1;
+ }
+ struct stat st;
+ int stat_rc = fstat(fd, &st);
+ if (stat_rc != 0 || !S_ISREG(st.st_mode)) {
+ int e = stat_rc != 0 ? errno : EINVAL;
+ close(fd);
+ errno = e;
+ if (err)
+ *err = xstrdup(stat_rc != 0 ? strerror(e) : "not a regular file");
+ return -1;
+ }
+ xbuf b;
+ xbuf_init(&b);
+ char chunk[8192];
+ int rc = 0;
+ for (;;) {
+ size_t room = max_bytes - b.len;
+ size_t want = room < sizeof chunk ? room + 1 : sizeof chunk;
+ ssize_t n = read(fd, chunk, want);
+ if (n > 0) {
+ if ((size_t)n > room) {
+ if (err)
+ *err = xstrdup("file exceeds configured size limit");
+ rc = -1;
+ break;
+ }
+ xbuf_putn(&b, chunk, (size_t)n);
+ } else if (n == 0) {
+ break;
+ } else if (errno != EINTR) {
+ if (err)
+ *err = xstrdup(strerror(errno));
+ rc = -1;
+ break;
+ }
+ }
+ close(fd);
+ if (rc != 0) {
+ xbuf_free(&b);
+ return -1;
+ }
+ size_t total = b.len;
+ if (out)
+ *out = xbuf_steal(&b);
+ if (out_len)
+ *out_len = total;
+ xbuf_free(&b);
+ return 0;
+}
+
+int write_file_from_buf(const char *path, const char *data, size_t len,
+ char **err) {
+ FILE *f = fopen(path, "wb");
+ if (!f) {
+ if (err)
+ *err = xstrdup(strerror(errno));
+ return -1;
+ }
+ if (len && fwrite(data, 1, len, f) != len) {
+ fclose(f);
+ if (err)
+ *err = xstrdup("write error");
+ return -1;
+ }
+ if (fclose(f) != 0) {
+ if (err)
+ *err = xstrdup("close error");
+ return -1;
+ }
+ return 0;
+}
+
+bool path_exists(const char *path) {
+ struct stat st;
+ return stat(path, &st) == 0;
+}
+
+const char *llsh_ca_bundle(void) {
+ const char *env = getenv("SSL_CERT_FILE");
+ if (env && env[0] && access(env, R_OK) == 0)
+ return env;
+ env = getenv("CURL_CA_BUNDLE");
+ if (env && env[0] && access(env, R_OK) == 0)
+ return env;
+ static const char *const candidates[] = {
+ "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", /* RHEL/Fedora */
+ "/etc/ssl/cert.pem", /* -> the above */
+ "/etc/pki/tls/certs/ca-bundle.crt", /* older RHEL */
+ "/etc/ssl/certs/ca-certificates.crt", /* Debian/Ubuntu */
+ "/etc/ssl/certs/ca-bundle.crt",
+ };
+ for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++)
+ if (access(candidates[i], R_OK) == 0)
+ return candidates[i];
+ return NULL;
+}
+
+int mkdir_p(const char *path) {
+ char *tmp = xstrdup(path);
+ int rc = 0;
+ for (char *p = tmp + 1; *p; p++) {
+ if (*p == '/') {
+ *p = '\0';
+ if (mkdir(tmp, 0755) != 0 && errno != EEXIST) {
+ rc = -1;
+ break;
+ }
+ *p = '/';
+ }
+ }
+ if (rc == 0 && mkdir(tmp, 0755) != 0 && errno != EEXIST)
+ rc = -1;
+ free(tmp);
+ return rc;
+}
+
+char *expand_tilde(const char *s) {
+ if (strcmp(s, "~") == 0) {
+ const char *home = getenv("HOME");
+ return xstrdup(home ? home : "/");
+ }
+ if (strncmp(s, "~/", 2) == 0) {
+ const char *home = getenv("HOME");
+ if (home)
+ return xstrjoin2(home, s + 1);
+ return xstrdup(s + 1); /* shell fallback: ~/x -> /x */
+ }
+ return xstrdup(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* Misc */
+/* ------------------------------------------------------------------ */
+
+double now_secs(void) {
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
+}
+
+void sleep_ms(unsigned long ms) {
+ struct timespec ts;
+ ts.tv_sec = ms / 1000;
+ ts.tv_nsec = (ms % 1000) * 1000000L;
+ while (nanosleep(&ts, &ts) == -1) {
+ /* EINTR: retry with remaining time */
+ }
+}
+
+char *itoa_dup(long long v) {
+ char tmp[32];
+ snprintf(tmp, sizeof tmp, "%lld", v);
+ return xstrdup(tmp);
+}
+
+char *dtoa_dup(double v) {
+ char tmp[64];
+ /* Only cast to long long when v is a finite, in-range whole number; the
+ * cast itself is undefined for NaN/inf or out-of-range values. */
+ int is_int = (v == v) && (v > -9.0e18) && (v < 9.0e18) && (v == (long long)v);
+ if (is_int) {
+ snprintf(tmp, sizeof tmp, "%lld", (long long)v);
+ } else {
+ snprintf(tmp, sizeof tmp, "%.15g", v);
+ }
+ return xstrdup(tmp);
+}
diff --git a/src/util.h b/src/util.h
new file mode 100644
index 0000000..41c8203
--- /dev/null
+++ b/src/util.h
@@ -0,0 +1,110 @@
+#ifndef LLSH_UTIL_H
+#define LLSH_UTIL_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <stdio.h>
+
+/* ------------------------------------------------------------------ */
+/* Growable byte buffer (NUL-terminated on steal) */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ char *data;
+ size_t len;
+ size_t cap;
+} xbuf;
+
+void xbuf_init(xbuf *b);
+void xbuf_free(xbuf *b);
+void xbuf_reset(xbuf *b);
+void xbuf_put(xbuf *b, const char *s);
+void xbuf_putn(xbuf *b, const char *s, size_t n);
+void xbuf_push(xbuf *b, char c);
+int xbuf_fmt(xbuf *b, const char *fmt, ...);
+char *xbuf_steal(xbuf *b); /* NUL-terminated string; buffer reset */
+
+/* curl write callback that appends to an xbuf (pass `&buf` as the userdata). */
+size_t xbuf_curl_write(char *data, size_t sz, size_t nm, void *ud);
+
+/* ------------------------------------------------------------------ */
+/* Strings */
+/* ------------------------------------------------------------------ */
+
+char *xstrdup(const char *s);
+char *xstrndup(const char *s, size_t n);
+int xstreq(const char *a, const char *b);
+/* Trim ASCII whitespace from [s, s+n): returns a pointer into s and sets
+ * *out_len (no copy). */
+const char *xtrim_span(const char *s, size_t n, size_t *out_len);
+/* Rust str::trim whitespace set. */
+bool utf8_is_ws(uint32_t cp);
+char *xstr_ltrim_dup(const char *s);
+char *xstr_rtrim_dup(const char *s);
+char *xstr_trim_dup(const char *s);
+char *xstrjoin2(const char *a, const char *b);
+#include <stdarg.h>
+char *xvsnprintf(const char *fmt, va_list ap);
+/* Variadic convenience wrapper: freshly allocated, printf-style. */
+char *xstrfmt(const char *fmt, ...);
+/* UTF-8 aware helpers */
+size_t utf8_len(const char *s); /* codepoint count */
+size_t utf8_byte_len(const char *s, size_t max_points);
+const char *utf8_next(const char *p, const char *end, size_t *cp);
+size_t utf8_display_width(const char *s); /* CJK fullwidth aware */
+bool utf8_is_valid(const char *s, size_t len);
+
+/* ------------------------------------------------------------------ */
+/* String list */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ char **items;
+ size_t len;
+ size_t cap;
+} strlist;
+
+void sl_init(strlist *l);
+void sl_free(strlist *l);
+void sl_push(strlist *l, char *owned); /* takes ownership */
+void sl_push_copy(strlist *l, const char *s);
+char *sl_pop_front(strlist *l); /* NULL when empty */
+
+/* ------------------------------------------------------------------ */
+/* Files */
+/* ------------------------------------------------------------------ */
+
+int read_file_to_buf(const char *path, char **out, size_t *out_len, char **err);
+int read_file_to_buf_limited(const char *path, size_t max_bytes, char **out,
+ size_t *out_len, char **err);
+int write_file_from_buf(const char *path, const char *data, size_t len, char **err);
+bool path_exists(const char *path);
+/* Create `path` and any missing parent directories (0755). 0 on success. */
+int mkdir_p(const char *path);
+
+/* Expand a leading `~` / `~/...` against $HOME (like a shell).
+ * Returns a freshly allocated string. */
+char *expand_tilde(const char *s);
+
+/* Resolve the system CA certificate bundle to pass as CURLOPT_CAINFO.
+ * The statically linked vendored OpenSSL has no usable default cert path
+ * (its --prefix points into the vendor tree, where no bundle is installed),
+ * so without this libcurl cannot verify any TLS peer. Honors
+ * $SSL_CERT_FILE / $CURL_CA_BUNDLE, then probes the usual distro locations.
+ * Returns NULL when nothing readable is found. */
+const char *llsh_ca_bundle(void);
+
+/* ------------------------------------------------------------------ */
+/* Misc */
+/* ------------------------------------------------------------------ */
+
+double now_secs(void);
+void sleep_ms(unsigned long ms);
+void *xmalloc(size_t n);
+void *xrealloc(void *p, size_t n);
+char *itoa_dup(long long v);
+/* Format a double the way Rust's Display does for common values */
+char *dtoa_dup(double v);
+
+#endif
diff --git a/src/verbose.cpp b/src/verbose.cpp
new file mode 100644
index 0000000..5de886f
--- /dev/null
+++ b/src/verbose.cpp
@@ -0,0 +1,55 @@
+#include "verbose.h"
+
+#include <cjson/cJSON.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+static bool g_verbose = false;
+static void (*g_sink)(const char *, void *) = NULL;
+static void *g_sink_ud = NULL;
+
+void verbose_set(bool on) {
+ g_verbose = on;
+}
+
+bool verbose_enabled(void) {
+ return g_verbose;
+}
+
+void verbose_set_sink(void (*sink)(const char *line, void *ud), void *ud) {
+ g_sink = sink;
+ g_sink_ud = ud;
+}
+
+void verbose_log(const char *event, const vlog_kv *kvs, size_t n) {
+ if (!g_verbose)
+ return;
+ cJSON *obj = cJSON_CreateObject();
+ cJSON_AddStringToObject(obj, "event", event);
+ for (size_t i = 0; i < n && kvs; i++) {
+ const vlog_kv *kv = &kvs[i];
+ switch (kv->kind) {
+ case VLOG_STR:
+ cJSON_AddStringToObject(obj, kv->key, kv->s ? kv->s : "");
+ break;
+ case VLOG_INT:
+ cJSON_AddNumberToObject(obj, kv->key, kv->i);
+ break;
+ case VLOG_BOOL:
+ cJSON_AddBoolToObject(obj, kv->key, kv->b);
+ break;
+ case VLOG_DBL:
+ cJSON_AddNumberToObject(obj, kv->key, kv->d);
+ break;
+ }
+ }
+ char *line = cJSON_PrintUnformatted(obj);
+ if (line) {
+ if (g_sink)
+ g_sink(line, g_sink_ud);
+ else
+ fprintf(stderr, "%s\n", line);
+ free(line);
+ }
+ cJSON_Delete(obj);
+}
diff --git a/src/verbose.h b/src/verbose.h
new file mode 100644
index 0000000..35c2002
--- /dev/null
+++ b/src/verbose.h
@@ -0,0 +1,35 @@
+#ifndef LLSH_VERBOSE_H
+#define LLSH_VERBOSE_H
+
+#include "util.h"
+
+/* Structured event logging (one JSON object per line) to stderr,
+ * enabled via --verbose / config. Mirrors verbose.rs. */
+
+typedef enum {
+ VLOG_STR,
+ VLOG_INT,
+ VLOG_BOOL,
+ VLOG_DBL,
+} vlog_kind;
+
+typedef struct {
+ const char *key;
+ vlog_kind kind;
+ const char *s;
+ long long i;
+ double d;
+ bool b;
+} vlog_kv;
+
+#define VLOG_STR(k, v) { (k), VLOG_STR, (v), 0, 0, false }
+#define VLOG_INT(k, v) { (k), VLOG_INT, NULL, (long long)(v), 0, false }
+#define VLOG_BOOL(k, v) { (k), VLOG_BOOL, NULL, 0, 0, (v) }
+#define VLOG_DBL(k, v) { (k), VLOG_DBL, NULL, 0, (v), false }
+
+void verbose_set(bool on);
+bool verbose_enabled(void);
+void verbose_set_sink(void (*sink)(const char *line, void *ud), void *ud);
+void verbose_log(const char *event, const vlog_kv *kvs, size_t n);
+
+#endif
diff --git a/tests/test_agent.cpp b/tests/test_agent.cpp
new file mode 100644
index 0000000..c5212ae
--- /dev/null
+++ b/tests/test_agent.cpp
@@ -0,0 +1,260 @@
+#include "agent.h"
+
+#include <cjson/cJSON.h>
+#include <curl/curl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+#define CHECK_EQ_STR(a, b, msg) \
+ do { \
+ checks++; \
+ const char *_a = (a), *_b = (b); \
+ if (!_a || (_b && strcmp(_a, _b) != 0)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \
+ __FILE__, __LINE__, (msg), _a ? _a : "(null)", \
+ _b ? _b : "(null)"); \
+ } \
+ } while (0)
+
+static config mkcfg(provider_kind p, const char *model, const char *base,
+ const char *key) {
+ config c;
+ memset(&c, 0, sizeof c);
+ c.provider = p;
+ c.model = xstrdup(model);
+ c.base_url = base ? xstrdup(base) : NULL;
+ c.api_key = key ? xstrdup(key) : NULL;
+ return c;
+}
+
+static void freecfg(config *c) {
+ free(c->model);
+ free(c->base_url);
+ free(c->api_key);
+}
+
+static void test_extract_openai_context_length(void) {
+ config c = mkcfg(PROV_OPENAI, "llm", NULL, NULL);
+ cJSON *j = cJSON_Parse("{\"id\":\"llm\",\"object\":\"model\","
+ "\"context_length\":262144}");
+ CHECK(extract_context_limit(j, &c) == 262144, "openai top-level limit");
+ cJSON_Delete(j);
+ j = cJSON_Parse("{\"id\":\"llm\",\"object\":\"model\"}");
+ CHECK(extract_context_limit(j, &c) == 0, "openai missing limit");
+ cJSON_Delete(j);
+ j = cJSON_Parse("{\"object\":\"list\",\"data\":["
+ "{\"id\":\"llm\",\"aliases\":[\"llm\"],"
+ "\"owned_by\":\"llamacpp\",\"meta\":{\"n_ctx\":131072}},"
+ "{\"id\":\"other\",\"meta\":{\"n_ctx\":8192}}]}");
+ CHECK(extract_context_limit(j, &c) == 131072,
+ "llama.cpp meta.n_ctx for matching model");
+ cJSON_Delete(j);
+ freecfg(&c);
+}
+
+static void test_openai_model_present(void) {
+ CHECK(openai_model_present("{\"data\":[{\"id\":\"llm\"},{\"id\":\"other\"}]}",
+ "llm"),
+ "id match in data[]");
+ CHECK(!openai_model_present("{\"data\":[{\"id\":\"llm\"},{\"id\":\"other\"}]}",
+ "nope"),
+ "id miss");
+ CHECK(openai_model_present(
+ "{\"models\":[{\"name\":\"llm\"}],"
+ "\"data\":[{\"id\":\"x\",\"aliases\":[\"llm\"]}]}",
+ "llm"),
+ "models[].name and data[].aliases variants");
+ CHECK(!openai_model_present("{\"data\":[]}", "llm"), "empty data[]");
+ CHECK(!openai_model_present("not json", "llm"), "malformed body");
+ /* parity: a non-array `data` blocks the `models` fallback (Rust
+ * get("data").or_else(|| get("models"))) */
+ CHECK(!openai_model_present(
+ "{\"data\":{\"id\":\"x\"},\"models\":[{\"id\":\"llm\"}]}", "llm"),
+ "non-array data does not fall back to models");
+}
+
+static void test_extract_ollama_context_length(void) {
+ config c = mkcfg(PROV_OLLAMA, "llama3.2", NULL, NULL);
+ cJSON *j = cJSON_Parse("{\"name\":\"llama3.2\","
+ "\"model_details\":{\"family\":\"llama\","
+ "\"context_length\":8192},"
+ "\"modelfiles\":[{\"parameters\":"
+ "{\"context_length\":4096}}]}");
+ CHECK(extract_context_limit(j, &c) == 8192, "ollama model_details");
+ cJSON_Delete(j);
+ j = cJSON_Parse("{\"modelfiles\":[{\"parameters\":"
+ "{\"context_length\":4096}}]}");
+ CHECK(extract_context_limit(j, &c) == 4096, "ollama modelfiles fallback");
+ cJSON_Delete(j);
+ j = cJSON_Parse("{\"name\":\"llama3.2\",\"model_details\":{}}");
+ CHECK(extract_context_limit(j, &c) == 0, "ollama absent");
+ cJSON_Delete(j);
+ freecfg(&c);
+}
+
+static void test_extract_anthropic_max_tokens(void) {
+ cJSON *j = cJSON_Parse("{\"data\":["
+ "{\"id\":\"claude-haiku\",\"max_tokens\":8192},"
+ "{\"id\":\"claude-sonnet-4-6\",\"max_tokens\":64000}]}");
+ config c = mkcfg(PROV_ANTHROPIC, "claude-sonnet-4-6", NULL, NULL);
+ CHECK(extract_context_limit(j, &c) == 64000, "matching model wins");
+ freecfg(&c);
+ c = mkcfg(PROV_ANTHROPIC, "unknown-model", NULL, NULL);
+ CHECK(extract_context_limit(j, &c) == 8192,
+ "unknown model falls back to the first entry");
+ freecfg(&c);
+ cJSON_Delete(j);
+ j = cJSON_Parse("{\"data\":[]}");
+ c = mkcfg(PROV_ANTHROPIC, "unknown-model", NULL, NULL);
+ CHECK(extract_context_limit(j, &c) == 0, "empty data[] -> none");
+ cJSON_Delete(j);
+ freecfg(&c);
+}
+
+static void test_anthropic_model_present(void) {
+ const char *body =
+ "{\"data\":[{\"id\":\"claude-haiku\"},{\"id\":\"claude-sonnet-4-6\"}]}";
+ CHECK(anthropic_model_present(body, "claude-sonnet-4-6"), "id match");
+ CHECK(!anthropic_model_present(body, "claude-unknown"), "id miss");
+ CHECK(!anthropic_model_present("not json", "claude-haiku"),
+ "malformed body never present");
+ CHECK(!anthropic_model_present("{\"data\":[]}", "claude-haiku"),
+ "empty list");
+ CHECK(!anthropic_model_present("{\"data\":{\"id\":\"x\"}}", "x"),
+ "data not an array");
+}
+
+static void test_probe_helpers(void) {
+ provider_probe ok = {true, xstrdup("model `llm` reachable at http://x")};
+ CHECK(ok.ok, "ok flag");
+ CHECK_EQ_STR(ok.detail, "model `llm` reachable at http://x", "ok detail");
+ provider_probe_free(&ok);
+ CHECK(ok.detail == NULL, "probe_free releases detail");
+ provider_probe bad = {false, xstrdup("could not reach http://x "
+ "(connection: timeout)")};
+ CHECK(!bad.ok, "fail flag");
+ CHECK(strstr(bad.detail, "timeout") != NULL, "fail detail content");
+ provider_probe_free(&bad);
+ CHECK(bad.detail == NULL, "probe_free releases failed detail");
+}
+
+static void test_default_base_urls(void) {
+ CHECK_EQ_STR(agent_default_base_url(PROV_OPENAI), "https://api.openai.com/v1",
+ "openai default base");
+ CHECK_EQ_STR(agent_default_base_url(PROV_ANTHROPIC),
+ "https://api.anthropic.com", "anthropic default base");
+ CHECK_EQ_STR(agent_default_base_url(PROV_OLLAMA), "http://localhost:11434",
+ "ollama default base");
+}
+
+static void test_models_url(void) {
+ config c = mkcfg(PROV_OPENAI, "llm", NULL, NULL);
+ char *u = agent_models_url(&c);
+ CHECK_EQ_STR(u, "https://api.openai.com/v1/models", "openai default url");
+ free(u);
+ freecfg(&c);
+
+ c = mkcfg(PROV_OPENAI, "llm", "http://localhost:8080/v1/", "k");
+ u = agent_models_url(&c);
+ CHECK_EQ_STR(u, "http://localhost:8080/v1/models",
+ "custom base, trailing slash trimmed");
+ free(u);
+ freecfg(&c);
+
+ c = mkcfg(PROV_ANTHROPIC, "claude-sonnet-4-6", NULL, "k");
+ u = agent_models_url(&c);
+ CHECK_EQ_STR(u, "https://api.anthropic.com/v1/models?limit=100",
+ "anthropic url");
+ free(u);
+ freecfg(&c);
+
+ c = mkcfg(PROV_OLLAMA, "llama3.2", NULL, NULL);
+ u = agent_models_url(&c);
+ CHECK_EQ_STR(u, "http://localhost:11434/api/show?name=llama3.2",
+ "ollama url");
+ free(u);
+ freecfg(&c);
+
+ c = mkcfg(PROV_OLLAMA, "llama 3.2", "http://host:1/x/", NULL);
+ u = agent_models_url(&c);
+ CHECK(strstr(u, "name=llama%203.2") != NULL, "ollama name is url-encoded");
+ free(u);
+ freecfg(&c);
+}
+
+static void test_auth(void) {
+ config c = mkcfg(PROV_OPENAI, "llm", NULL, "sk-1");
+ char *h = NULL, *v = NULL;
+ agent_auth(&c, &h, &v);
+ CHECK_EQ_STR(h, "Authorization", "openai auth header");
+ CHECK_EQ_STR(v, "Bearer sk-1", "openai bearer value");
+ free(h);
+ free(v);
+ freecfg(&c);
+
+ c = mkcfg(PROV_OPENAI, "llm", "http://x", NULL);
+ agent_auth(&c, &h, &v);
+ CHECK(h == NULL && v == NULL, "openai without key: no auth");
+ free(h);
+ free(v);
+ freecfg(&c);
+
+ c = mkcfg(PROV_ANTHROPIC, "m", NULL, "ak");
+ agent_auth(&c, &h, &v);
+ CHECK_EQ_STR(h, "x-api-key", "anthropic auth header");
+ CHECK_EQ_STR(v, "ak", "anthropic key passthrough");
+ free(h);
+ free(v);
+ freecfg(&c);
+
+ c = mkcfg(PROV_OLLAMA, "m", NULL, "ignored");
+ agent_auth(&c, &h, &v);
+ CHECK(h == NULL && v == NULL, "ollama sends no auth");
+ free(h);
+ free(v);
+ freecfg(&c);
+}
+
+static void test_http_get_error_path(void) {
+ long status = 0;
+ char *body = NULL, *err = NULL;
+ /* port 1 on localhost: connection refused, no server expected */
+ int rc = agent_http_get("http://127.0.0.1:1/models", NULL, NULL, &status,
+ &body, &err);
+ CHECK(rc == -1, "unreachable endpoint -> transport failure");
+ CHECK(err != NULL, "transport error message present");
+ CHECK(body == NULL, "no body on transport failure");
+ free(err);
+ free(body);
+}
+
+int main(void) {
+ curl_global_init(CURL_GLOBAL_DEFAULT);
+ test_extract_openai_context_length();
+ test_openai_model_present();
+ test_extract_ollama_context_length();
+ test_extract_anthropic_max_tokens();
+ test_anthropic_model_present();
+ test_probe_helpers();
+ test_default_base_urls();
+ test_models_url();
+ test_auth();
+ test_http_get_error_path();
+ curl_global_cleanup();
+ printf("test_agent: %d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_clio.c b/tests/test_clio.c
new file mode 100644
index 0000000..535df1d
--- /dev/null
+++ b/tests/test_clio.c
@@ -0,0 +1,884 @@
+/* test_clio.c — standalone test suite for clio.h (+ escape.h).
+ * Zero dependencies: single TU, no linked libraries (defines
+ * ESCAPE_IMPLEMENTATION to pull in the esc_* symbols). */
+#define ESCAPE_IMPLEMENTATION
+#include "clio.h"
+
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+/* Capture stdout (fd 1) into a malloc'd buffer across the test body. */
+typedef struct cap {
+ int pfd[2];
+ int saved;
+} cap_t;
+
+/* dup2() returns the target fd (STDOUT_FILENO == 1) on success, -1 on
+ * failure — compare against -1, not 0. */
+static int cap_begin(cap_t *c) {
+ if (pipe(c->pfd) != 0)
+ return -1;
+ c->saved = dup(STDOUT_FILENO);
+ if (c->saved < 0)
+ return -1;
+ if (dup2(c->pfd[1], STDOUT_FILENO) == -1) {
+ close(c->pfd[0]);
+ close(c->pfd[1]);
+ close(c->saved);
+ return -1;
+ }
+ close(c->pfd[1]);
+ return 0;
+}
+
+static char *cap_end(cap_t *c, size_t *len) {
+ fflush(stdout);
+ if (dup2(c->saved, STDOUT_FILENO) == -1) {
+ close(c->saved);
+ return NULL;
+ }
+ close(c->saved);
+ size_t cap = 256;
+ size_t n = 0;
+ char *buf = (char *) malloc(cap);
+ char tmp[1024];
+ ssize_t r;
+ while ((r = read(c->pfd[0], tmp, sizeof tmp)) > 0) {
+ if (n + (size_t) r > cap) {
+ cap = (cap + (size_t) r) * 2;
+ buf = (char *) realloc(buf, cap);
+ }
+ memcpy(buf + n, tmp, (size_t) r);
+ n += (size_t) r;
+ }
+ close(c->pfd[0]);
+ buf[n] = '\0'; /* callers use both strlen() and len */
+ *len = n;
+ return buf;
+}
+
+static int contains(const char *hay, size_t len, const char *needle) {
+ size_t nl = strlen(needle);
+ if (nl > len)
+ return 0;
+ for (size_t i = 0; i + nl <= len; i++)
+ if (memcmp(hay + i, needle, nl) == 0)
+ return 1;
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Text: UTF-8 + width */
+/* ------------------------------------------------------------------ */
+
+static void test_utf8(void) {
+ u32 cp;
+ char out[8];
+ CHECK(clio_utf8_next("a", &cp) == 1 && cp == 'a', "ascii decode");
+ CHECK(clio_utf8_next("\xC3\xA9", &cp) == 2 && cp == 0xE9, "latin1 decode");
+ CHECK(clio_utf8_next("\xE4\xB8\xAD", &cp) == 3 && cp == 0x4E2D,
+ "cjk decode");
+ CHECK(clio_utf8_next("\xF0\x9F\x98\x80", &cp) == 4 && cp == 0x1F600,
+ "emoji decode");
+ /* truncated sequence: consumes what is left */
+ CHECK(clio_utf8_next("\xE4\xB8", &cp) == 2, "truncated decode");
+ CHECK(clio_utf8_enc(0x4E2D, out) == 3 && memcmp(out, "\xE4\xB8\xAD", 3) == 0,
+ "cjk encode");
+ CHECK(clio_utf8_enc(0x1F600, out) == 4, "emoji encode");
+ CHECK(clio_utf8_enc(0xD800, out) == -1, "surrogate rejected");
+ CHECK(clio_utf8_enc(0x110000, out) == -1, "out of range rejected");
+
+ CHECK(clio_text_width("abc") == 3, "ascii width");
+ CHECK(clio_text_width("h\xC3\xA9llo") == 5, "latin width");
+ CHECK(clio_text_width("\xE4\xB8\xAD\xE6\x96\x87") == 4, "cjk width");
+ CHECK(clio_text_width("\xF0\x9F\x98\x80") == 2, "emoji width");
+ CHECK(clio_cp_width('\n') == 0, "newline zero width");
+
+ const char *s = "ab\xE4\xB8\xAD" "c"; /* a b 中 c */
+ CHECK(clio_char_start(s, 4) == 2, "char_start cjk");
+ CHECK(clio_char_start(s, 2) == 2, "char_start at boundary");
+ CHECK(clio_char_start(s, 1) == 1, "char_start ascii");
+ CHECK(clio_char_end(s, 6, 2) == 5, "char_end cjk");
+ CHECK(clio_char_end(s, 6, 5) == 6, "char_end last");
+
+ /* semantics verified against bash/readline Ctrl+W via pty:
+ * "foo bar baz"@12 -> 7, "foo bar"@8 -> 3, "a_b c"@5 -> 3 */
+ CHECK(clio_word_back("foo bar baz", 12) == 7, "word_back word");
+ CHECK(clio_word_back("foo bar", 8) == 3, "word_back across spaces");
+ CHECK(clio_word_back("a_b c", 5) == 3, "word_back underscore");
+}
+
+/* ------------------------------------------------------------------ */
+/* Canvas */
+/* ------------------------------------------------------------------ */
+
+static void test_buf(void) {
+ clio_buf b;
+ clio_style s = clio_style_default();
+ s.fg = 248;
+ s.bg = 238;
+ s.attr = CLIO_BOLD | CLIO_REVERSE;
+
+ CHECK(clio_buf_init(&b, 3, 8) == 0, "buf init");
+ CHECK(b.rows == 3 && b.cols == 8, "buf dims");
+
+ /* default cell is blank + default style */
+ clio_cell *c0 = b.cells;
+ CHECK(c0->ch == 0 && c0->fg == 0xFFFF && c0->bg == 0xFFFF && c0->attr == 0,
+ "default cell");
+
+ CHECK(clio_buf_put(&b, 0, 0, "hi", s, 0) == 2, "put ascii");
+ CHECK(b.cells[0].ch == 'h' && b.cells[1].ch == 'i', "put stored");
+ CHECK(b.cells[0].fg == 248 && b.cells[0].bg == 238, "put style");
+ CHECK(b.cells[0].attr == (CLIO_BOLD | CLIO_REVERSE), "put attr");
+ CHECK(b.cells[2].ch == 0, "put bounds");
+
+ /* clipping: no wrap */
+ CHECK(clio_buf_put(&b, 0, 6, "abc", s, 0) == 2, "clip no wrap");
+ CHECK(b.cells[7].ch == 'b', "clip stored edge");
+ clio_buf_clear(&b);
+
+ /* wrapping onto next row */
+ CHECK(clio_buf_put(&b, 0, 6, "abcd", s, 1) == 4, "wrap put");
+ CHECK(b.cells[7].ch == 'b', "wrap first row edge");
+ CHECK(b.cells[8].ch == 'c' && b.cells[9].ch == 'd', "wrap second row");
+
+ clio_buf_clear(&b);
+
+ /* newline moves to next row at col 0 */
+ clio_buf_put(&b, 0, 0, "a\nb", s, 1);
+ CHECK(b.cells[0].ch == 'a' && b.cells[8].ch == 'b', "newline put");
+
+ clio_buf_clear(&b);
+
+ /* wide char occupies two cells */
+ clio_buf_put(&b, 0, 0, "\xE4\xB8\xAD\xE6\x96\x87", s, 1);
+ CHECK(b.cells[0].ch == 0x4E2D && b.cells[1].ch == 0, "wide first cell");
+ CHECK(b.cells[2].ch == 0x6587 && b.cells[3].ch == 0, "wide second cell");
+
+ clio_buf_clear(&b);
+
+ /* zero-width chars consume no cell */
+ clio_buf_put(&b, 0, 0, "\xE2\x80\x8Bx", s, 1); /* ZWSP + x */
+ CHECK(b.cells[0].ch == 'x', "zero width skips");
+
+ clio_buf_clear(&b);
+
+ /* fill + clear region */
+ clio_buf_fill_region(&b, 0, 0, 1, 3, '*', s);
+ CHECK(b.cells[0].ch == '*' && b.cells[3].ch == '*', "fill row 0");
+ CHECK(b.cells[8].ch == '*' && b.cells[11].ch == '*', "fill row 1");
+ CHECK(b.cells[4].ch == 0, "fill bounds");
+ clio_buf_clear_region(&b, 0, 1, 1, 2);
+ CHECK(b.cells[0].ch == '*' && b.cells[1].ch == 0 && b.cells[2].ch == 0,
+ "clear region");
+ CHECK(b.cells[10].ch == 0 && b.cells[11].ch == '*', "clear region row 1");
+ clio_buf_clear(&b);
+
+ /* box 2x4 in top-left */
+ clio_buf_box(&b, 0, 0, 4, 2, '#', s);
+ CHECK(b.cells[0].ch == '#' && b.cells[3].ch == '#', "box top");
+ CHECK(b.cells[8].ch == '#' && b.cells[11].ch == '#', "box bottom");
+ clio_buf_clear(&b);
+
+ /* scroll up: rows 0..2 shift, bottom becomes blank */
+ clio_buf_put(&b, 0, 0, "aa", s, 0);
+ clio_buf_put(&b, 1, 0, "bb", s, 0);
+ clio_buf_put(&b, 2, 0, "cc", s, 0);
+ clio_buf_scroll_up(&b, 0, 0, 2, 1);
+ CHECK(b.cells[0].ch == 'b' && b.cells[8].ch == 'c', "scroll moved");
+ CHECK(b.cells[16].ch == 0, "scroll bottom blank");
+
+ clio_buf_free(&b);
+ CHECK(b.cells == NULL, "buf free");
+
+ /* init with zero dims clamps to 1x1 */
+ CHECK(clio_buf_init(&b, 0, 0) == 0 && b.rows == 1 && b.cols == 1,
+ "buf init clamps");
+ clio_buf_free(&b);
+}
+
+/* ------------------------------------------------------------------ */
+/* Key decoder */
+/* ------------------------------------------------------------------ */
+
+static size_t decode(clio_keydec *d, const char *in, clio_key *k, size_t cap) {
+ clio_keydec_init(d);
+ return clio_keydec_feed(d, (const unsigned char *) in, strlen(in), k, cap);
+}
+
+static void test_keydec(void) {
+ clio_keydec d;
+ clio_key k[16];
+ size_t n;
+
+ n = decode(&d, "\x1B[C", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_RIGHT, "csi right");
+ n = decode(&d, "\x1B[D", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_LEFT, "csi left");
+ n = decode(&d, "\x1B[A", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_UP, "csi up");
+ n = decode(&d, "\x1B[B", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_DOWN, "csi down");
+ n = decode(&d, "\x1B[H", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_HOME, "csi home");
+ n = decode(&d, "\x1B[F", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_END, "csi end");
+ n = decode(&d, "\x1B[Z", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_BACKTAB, "csi backtab");
+ n = decode(&d, "\x1B[1~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_HOME, "csi 1~ home");
+ n = decode(&d, "\x1B[7~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_HOME, "csi 7~ home");
+ n = decode(&d, "\x1B[3~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_DELETE, "csi 3~ delete");
+ n = decode(&d, "\x1B[4~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_END, "csi 4~ end");
+ n = decode(&d, "\x1B[5~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_PAGEUP, "csi 5~ pageup");
+ n = decode(&d, "\x1B[6~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_PAGEDOWN, "csi 6~ pagedown");
+
+ n = decode(&d, "\x1BOA", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_UP, "ss3 up");
+ n = decode(&d, "\x1BOF", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_END, "ss3 end");
+
+ n = decode(&d, "\t\r\x7F\x08", k, 16);
+ CHECK(n == 4 && k[0].kind == CLIOK_TAB && k[1].kind == CLIOK_ENTER &&
+ k[2].kind == CLIOK_BACKSPACE && k[3].kind == CLIOK_BACKSPACE,
+ "tab enter backspace del8");
+
+ n = decode(&d, "\x01\x03\x04\x05\x0B\x0C\x15\x17\x19\x1A", k, 16);
+ CHECK(n == 10 && k[0].kind == CLIOK_CTRL_A && k[1].kind == CLIOK_CTRL_C &&
+ k[2].kind == CLIOK_CTRL_D && k[3].kind == CLIOK_CTRL_E &&
+ k[4].kind == CLIOK_CTRL_K && k[5].kind == CLIOK_CTRL_L &&
+ k[6].kind == CLIOK_CTRL_U && k[7].kind == CLIOK_CTRL_W &&
+ k[8].kind == CLIOK_CTRL_Y && k[9].kind == CLIOK_CTRL_Z,
+ "ctrl letters");
+
+ n = decode(&d, "\x07", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_CTRL_G, "ctrl g");
+ n = decode(&d, "\x1B", k, 16);
+ CHECK(n == 0, "bare esc not emitted yet");
+ CHECK(clio_keydec_flush(&d, &k[0]) == 1 && k[0].kind == CLIOK_ESC,
+ "esc flush");
+
+ n = decode(&d, "h\xC3\xA9\xE4\xB8\xAD", k, 16);
+ CHECK(n == 3 && k[0].cp == 'h' && k[1].cp == 0xE9 && k[2].cp == 0x4E2D,
+ "utf8 keys");
+
+ /* kitty protocol */
+ n = decode(&d, "\x1B[13u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_ENTER, "kitty enter");
+ n = decode(&d, "\x1B[127u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_DELETE, "kitty delete");
+ n = decode(&d, "\x1B[9u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_TAB, "kitty tab");
+ n = decode(&d, "\x1B[27u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_ESC, "kitty esc");
+
+ /* bracketed paste: LF inside paste is a char; start/end emit no key */
+ n = decode(&d, "\x1B[200~a\nb\x1B[201~", k, 16);
+ CHECK(n == 3 && k[0].cp == 'a' && k[1].cp == '\n' && k[2].cp == 'b',
+ "bracketed paste");
+
+ /* bare LF outside paste: dead key */
+ n = decode(&d, "\n", k, 16);
+ CHECK(n == 0, "bare lf dead");
+
+ /* split feeds: escape sequence across chunk boundaries */
+ clio_keydec_init(&d);
+ n = clio_keydec_feed(&d, (const unsigned char *) "\x1B", 1, k, 16);
+ CHECK(n == 0, "split esc");
+ n = clio_keydec_feed(&d, (const unsigned char *) "[", 1, k, 16);
+ CHECK(n == 0, "split [");
+ n = clio_keydec_feed(&d, (const unsigned char *) "C", 1, k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_RIGHT, "split C");
+
+ /* split UTF-8 across chunk boundaries */
+ clio_keydec_init(&d);
+ n = clio_keydec_feed(&d, (const unsigned char *) "\xC3", 1, k, 16);
+ CHECK(n == 0, "split utf8 lead");
+ n = clio_keydec_feed(&d, (const unsigned char *) "\xA9", 1, k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_CHAR && k[0].cp == 0xE9, "split utf8");
+
+ /* unknown CSI emits UNKNOWN so the app can react (e.g. beep) */
+ n = decode(&d, "\x1B[15~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_UNKNOWN, "unknown csi -> UNKNOWN");
+ /* cursor position report swallowed */
+ n = decode(&d, "\x1B[12;40R", k, 16);
+ CHECK(n == 0, "cpr swallowed");
+}
+
+/* ------------------------------------------------------------------ */
+/* Color policy */
+/* ------------------------------------------------------------------ */
+
+static void setenv0(const char *k, const char *v) {
+ if (v)
+ setenv(k, v, 1);
+ else
+ unsetenv(k);
+}
+
+static void test_color_policy(void) {
+ setenv0("CLICOLOR_FORCE", NULL);
+ setenv0("NO_COLOR", NULL);
+ setenv0("CI", NULL);
+ setenv0("TERM", NULL);
+ CHECK(clio_color_policy(1) == 1, "tty on");
+ CHECK(clio_color_policy(0) == 0, "notty off");
+ setenv0("NO_COLOR", "1");
+ CHECK(clio_color_policy(1) == 0, "NO_COLOR off");
+ setenv0("NO_COLOR", NULL);
+ setenv0("TERM", "dumb");
+ CHECK(clio_color_policy(1) == 0, "TERM=dumb off");
+ setenv0("TERM", NULL);
+ setenv0("CI", "true");
+ CHECK(clio_color_policy(1) == 0, "CI off");
+ setenv0("CI", NULL);
+ setenv0("CLICOLOR_FORCE", "1");
+ CHECK(clio_color_policy(0) == 1, "force on, notty");
+ setenv0("NO_COLOR", "1");
+ CHECK(clio_color_policy(1) == 1, "force beats NO_COLOR");
+ setenv0("CLICOLOR_FORCE", "0");
+ CHECK(clio_color_policy(1) == 0, "force 0 off");
+ setenv0("CLICOLOR_FORCE", NULL);
+}
+
+/* ------------------------------------------------------------------ */
+/* Style emission */
+/* ------------------------------------------------------------------ */
+
+static void test_fstyle(void) {
+ cap_t c;
+ clio_style s = clio_style_default();
+ s.fg = 38;
+ s.bg = 48;
+ s.attr = CLIO_BOLD | CLIO_UNDERLINE;
+
+ CHECK(cap_begin(&c) == 0, "cap begin 1");
+ clio_fstyle(stdout, s);
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(out != NULL && len > 0, "fstyle wrote");
+ if (out) {
+ /* exact sequence: reset, bold, underline, fg, bg (in that order) */
+ CHECK(contains(out, len, ESC_RESET) == 1, "fstyle reset");
+ CHECK(contains(out, len, ESC_BOLD) == 1, "fstyle bold");
+ CHECK(contains(out, len, ESC_UNDERLINE) == 1, "fstyle underline");
+ CHECK(contains(out, len, ESC_COLOR_FG_256(38)) == 1, "fstyle fg256");
+ CHECK(contains(out, len, ESC_COLOR_BG_256(48)) == 1, "fstyle bg256");
+ CHECK(contains(out, len, "48;5;48m") == 1, "bg uses 48 not 38");
+ CHECK(contains(out, len, ESC_ITALIC) == 0, "no italic");
+ /* reset first, fg before bg */
+ const char *rp = strstr(out, ESC_RESET);
+ const char *fp = strstr(out, ESC_COLOR_FG_256(38));
+ const char *bp = strstr(out, ESC_COLOR_BG_256(48));
+ CHECK(rp && fp && bp && rp < fp && fp < bp, "fstyle order");
+ free(out);
+ }
+
+ /* default style: only reset */
+ CHECK(cap_begin(&c) == 0, "cap begin 2");
+ clio_fstyle(stdout, clio_style_default());
+ out = cap_end(&c, &len);
+ CHECK(out != NULL && strcmp(out, ESC_RESET) == 0, "default = reset only");
+ free(out);
+}
+
+/* ------------------------------------------------------------------ */
+/* Flush (non-tty plain + tty diff) */
+/* ------------------------------------------------------------------ */
+
+static void test_flush_plain(void) {
+ clio_term t;
+ clio_term_init(&t);
+ clio_buf b;
+ clio_buf_init(&b, 2, 8);
+ clio_style s = clio_style_default();
+ s.fg = 248;
+ clio_buf_put(&b, 0, 0, "hi", s, 0);
+ clio_buf_put(&b, 1, 0, "yo", s, 0);
+ cap_t c;
+ CHECK(cap_begin(&c) == 0, "cap begin");
+ clio_flush(&t, &b);
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(out != NULL, "cap end");
+ CHECK(len > 0, "plain wrote");
+ CHECK(contains(out, len, "hi"), "plain row 0");
+ CHECK(contains(out, len, "yo"), "plain row 1");
+ CHECK(contains(out, len, "\x1B") == 0, "plain has no escapes");
+ CHECK(contains(out, len, "hi\nyo\n") == 1, "plain exact lines");
+ free(out);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+}
+
+static void test_flush_diff(void) {
+ clio_term t;
+ clio_term_init(&t);
+ /* force tty mode + fixed size for a deterministic test */
+ t.is_tty = 1;
+ t.rows = 3;
+ t.cols = 8;
+
+ clio_buf a;
+ clio_buf_init(&a, 3, 8);
+ clio_style s = clio_style_default();
+ clio_buf_put(&a, 0, 0, "row0", s, 0);
+ clio_buf_put(&a, 1, 0, "row1", s, 0);
+ clio_buf_put(&a, 2, 0, "row2", s, 0);
+
+ cap_t c;
+ CHECK(cap_begin(&c) == 0, "cap begin 1");
+ clio_flush(&t, &a);
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_CLEAR_SCREEN) == 1, "first flush clears");
+ CHECK(contains(out, len, "\x1B[1;1H") == 1, "first flush row 1 pos");
+ CHECK(contains(out, len, "\x1B[3;1H") == 1, "first flush row 3 pos");
+ CHECK(contains(out, len, "row0") == 1, "first flush content");
+ free(out);
+
+ /* change only row 2 */
+ clio_buf b;
+ clio_buf_init(&b, 3, 8);
+ clio_buf_put(&b, 0, 0, "row0", s, 0);
+ clio_buf_put(&b, 1, 0, "row1", s, 0);
+ clio_buf_put(&b, 2, 0, "CHG2", s, 0);
+
+ CHECK(cap_begin(&c) == 0, "cap begin 2");
+ clio_flush(&t, &b);
+ out = cap_end(&c, &len);
+ CHECK(contains(out, len, "\x1B[3;1H") == 1, "diff row 3 pos");
+ CHECK(contains(out, len, "CHG2") == 1, "diff row 2 content");
+ CHECK(contains(out, len, "\x1B[1;1H") == 0, "diff skips row 1");
+ CHECK(contains(out, len, ESC_CLEAR_SCREEN) == 0, "diff no clear");
+ CHECK(contains(out, len, "row0") == 0, "diff skips row 0 content");
+ free(out);
+
+ /* size change forces full repaint */
+ clio_buf d2;
+ clio_buf_init(&d2, 2, 4);
+ clio_buf_put(&d2, 0, 0, "xy", s, 0);
+ clio_buf_put(&d2, 1, 0, "zw", s, 0);
+ clio_buf_free(&t.last);
+ t.has_last = 0;
+ t.rows = 2;
+ t.cols = 4;
+ CHECK(cap_begin(&c) == 0, "cap begin 3");
+ clio_flush(&t, &d2);
+ out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_CLEAR_SCREEN) == 1, "resize full repaint");
+ CHECK(contains(out, len, "\x1B[1;1H") == 1, "resize row 1");
+ free(out);
+
+ clio_buf_free(&a);
+ clio_buf_free(&b);
+ clio_buf_free(&d2);
+ clio_term_free(&t);
+}
+
+/* ------------------------------------------------------------------ */
+/* Term toggles emit the right codes */
+/* ------------------------------------------------------------------ */
+
+static void test_term_toggles(void) {
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ cap_t c;
+ CHECK(cap_begin(&c) == 0, "cap begin");
+ clio_term_alt_on(&t);
+ clio_term_cursor_hide(&t);
+ clio_term_paste_on(&t);
+ clio_term_kitty_on(&t);
+ clio_term_bell(&t);
+ clio_term_alt_on(&t); /* idempotent: no second emission */
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_ALT_SCREEN_ON) == 1, "alt on");
+ CHECK(contains(out, len, ESC_ALT_SCREEN_ON) == 1, "alt on once");
+ CHECK(contains(out, len, ESC_CURSOR_HIDE) == 1, "cursor hide");
+ CHECK(contains(out, len, ESC_BRACKETED_PASTE_ON) == 1, "paste on");
+ CHECK(contains(out, len, ESC_KITTY_ON) == 1, "kitty on");
+ CHECK(contains(out, len, ESC_BELL) == 1, "bell");
+ free(out);
+
+ /* shutdown restores everything, in reverse, once each */
+ CHECK(cap_begin(&c) == 0, "cap begin 2");
+ clio_term_shutdown(&t);
+ out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_KITTY_OFF) == 1, "kitty off");
+ CHECK(contains(out, len, ESC_BRACKETED_PASTE_OFF) == 1, "paste off");
+ CHECK(contains(out, len, ESC_CURSOR_SHOW) == 1, "cursor show");
+ CHECK(contains(out, len, ESC_ALT_SCREEN_OFF) == 1, "alt off");
+ /* order: kitty first, alt last */
+ if (out) {
+ const char *kp = strstr(out, ESC_KITTY_OFF);
+ const char *ap = strstr(out, ESC_ALT_SCREEN_OFF);
+ CHECK(kp && ap && kp < ap, "shutdown order");
+ }
+ free(out);
+ clio_term_free(&t);
+}
+
+/* ------------------------------------------------------------------ */
+/* Readline (scripted key source) */
+/* ------------------------------------------------------------------ */
+
+typedef struct keys {
+ clio_key *ks;
+ size_t n;
+ size_t i;
+} keys_t;
+
+static int keys_next(void *ud, clio_key *k, int timeout_ms) {
+ (void) timeout_ms;
+ keys_t *s = (keys_t *) ud;
+ if (s->i >= s->n)
+ return 0;
+ *k = s->ks[s->i++];
+ return 1;
+}
+
+static void push(keys_t *s, int kind, u32 cp) {
+ s->ks[s->n].kind = kind;
+ s->ks[s->n].cp = cp;
+ s->n++;
+}
+
+static char *run_readline(keys_t *s, int echo, int rows, int cols) {
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ t.rows = (size_t) rows;
+ t.cols = (size_t) cols;
+ clio_buf b;
+ clio_buf_init(&b, (size_t) rows, (size_t) cols);
+ clio_style p = clio_style_default();
+ clio_style st = clio_style_default();
+ s->i = 0;
+ char *r = clio_readline(&t, &b, 0, "> ", p, st, echo, keys_next, s, 0);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+ return r;
+}
+
+static void test_readline(void) {
+ keys_t s;
+ clio_key ks[64];
+ s.ks = ks;
+ s.n = 0;
+ char *r;
+
+ /* type "ab", backspace, "c" -> "ac" */
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r != NULL && strcmp(r, "ac") == 0, "insert backspace");
+ free(r);
+
+ /* left arrow + insert: caret between a and c, x goes there -> "axc" */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_CHAR, 'x');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "axc") == 0, "left insert");
+ free(r);
+
+ /* ctrl+a, 'z' -> "zaxc" */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'x');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_CTRL_A, 0);
+ push(&s, CLIOK_CHAR, 'z');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "zaxc") == 0, "ctrl+a insert");
+ free(r);
+
+ /* ctrl+u clears line -> "" (submit empty) */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CTRL_U, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+u clears all");
+
+ /* ctrl+a then ctrl+k kills whole line -> empty submit */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_CTRL_A, 0);
+ push(&s, CLIOK_CTRL_K, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+k empty after");
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_CTRL_K, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "ab") == 0, "ctrl+k kills rest");
+ free(r);
+
+ /* ctrl+w deletes a word */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'h');
+ push(&s, CLIOK_CHAR, 'e');
+ push(&s, CLIOK_CHAR, 'l');
+ push(&s, CLIOK_CHAR, 'l');
+ push(&s, CLIOK_CHAR, 'o');
+ push(&s, CLIOK_CHAR, ' ');
+ push(&s, CLIOK_CHAR, 'w');
+ push(&s, CLIOK_CHAR, 'o');
+ push(&s, CLIOK_CHAR, 'r');
+ push(&s, CLIOK_CHAR, 'l');
+ push(&s, CLIOK_CHAR, 'd');
+ push(&s, CLIOK_CTRL_W, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ /* matches bash/readline: the preceding space is killed too */
+ CHECK(r && strcmp(r, "hello") == 0, "ctrl+w word");
+ free(r);
+
+ /* ctrl+c cancels */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CTRL_C, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+c cancels");
+
+ /* escape cancels */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_ESC, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "esc cancels");
+
+ /* ctrl+d on empty submits */
+ s.n = 0;
+ push(&s, CLIOK_CTRL_D, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+d empty submits");
+
+ /* ctrl+d deletes the char after the caret (left first) */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_CTRL_D, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "a") == 0, "ctrl+d delete");
+ free(r);
+
+ /* delete key removes forward char (caret in the middle) */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_DELETE, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "a") == 0, "delete forward");
+ free(r);
+
+ /* utf8 input round trip */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 0x4E2D); /* 中 */
+ push(&s, CLIOK_CHAR, 0x6587); /* 文 */
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "\xE4\xB8\xAD\xE6\x96\x87") == 0, "utf8 input");
+ free(r);
+
+ /* echo=0: stars rendered, but value is real text */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 0, 24, 80);
+ CHECK(r && strcmp(r, "abc") == 0, "echo off value");
+ free(r);
+}
+
+/* ------------------------------------------------------------------ */
+/* Long lines: no wrap, window scrolls, backspace spans the "wrap" */
+/* ------------------------------------------------------------------ */
+
+static void test_readline_long(void) {
+ /* 10 cols, prompt "> " => 8-cell text area. */
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ t.rows = 3;
+ t.cols = 10;
+ clio_buf b;
+ clio_buf_init(&b, 3, 10);
+ clio_style p = clio_style_default();
+ clio_style st = clio_style_default();
+ clio_str txt;
+ clio_str_init(&txt);
+ clio_str_ins(&txt, 0, "abcdefghijkl", 12);
+ size_t col;
+
+ /* caret at end: caret pinned to last col, tail visible, head scrolled */
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 12, st, 1);
+ CHECK(col == 9, "caret pinned last col");
+ CHECK(b.cells[2].ch == 'f' && b.cells[8].ch == 'l', "tail window");
+ CHECK(b.cells[9].ch == 0, "nothing past caret");
+
+ /* one backspace of scroll: window slides left by one */
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 11, st, 1);
+ CHECK(col == 9, "caret stays pinned");
+ CHECK(b.cells[2].ch == 'e' && b.cells[9].ch == 'l', "window slid");
+
+ /* caret in the middle: anchored at 0, right edge clipped */
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 3, st, 1);
+ CHECK(col == 5, "caret middle");
+ CHECK(b.cells[2].ch == 'a' && b.cells[9].ch == 'h', "head window clipped");
+
+ /* wide chars: 8 CJK = 16 cells; window must start on a char
+ * boundary and the caret clamp inside the row */
+ clio_str_free(&txt);
+ clio_str_init(&txt);
+ static const char cjk[] = "\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD"
+ "\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD";
+ clio_str_ins(&txt, 0, cjk, sizeof cjk - 1);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, sizeof cjk - 1, st, 1);
+ CHECK(col == 9, "wide caret clamped");
+ CHECK(b.cells[2].ch == 0x4E2D && b.cells[8].ch == 0x4E2D,
+ "wide window on boundary");
+
+ /* noecho: same windowing over star count */
+ clio_str_free(&txt);
+ clio_str_init(&txt);
+ clio_str_ins(&txt, 0, "abcdefghijkl", 12);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 12, st, 0);
+ CHECK(col == 9, "star caret pinned");
+ CHECK(b.cells[2].ch == '*' && b.cells[8].ch == '*' && b.cells[9].ch == 0,
+ "star window");
+
+ clio_str_free(&txt);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+
+ /* integration: type past the edge, backspace across the scroll
+ * boundary — the logical result must be exact */
+ keys_t s;
+ clio_key ks[64];
+ s.ks = ks;
+ s.n = 0;
+ for (char ch = 'a'; ch <= 'l'; ch++)
+ push(&s, CLIOK_CHAR, (u32) ch);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_ENTER, 0);
+ char *r = run_readline(&s, 1, 3, 10);
+ CHECK(r && strcmp(r, "abcdefghi") == 0, "backspace across wrap");
+ free(r);
+
+ /* left-arrow into the scrolled region, then backspace */
+ s.n = 0;
+ for (char ch = 'a'; ch <= 'l'; ch++)
+ push(&s, CLIOK_CHAR, (u32) ch);
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 3, 10);
+ CHECK(r && strcmp(r, "abcdefghijl") == 0, "left+backspace across wrap");
+ free(r);
+}
+
+/* ------------------------------------------------------------------ */
+/* Readline canvas paint */
+/* ------------------------------------------------------------------ */
+
+static void test_readline_paint(void) {
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ t.rows = 4;
+ t.cols = 20;
+ clio_buf b;
+ clio_buf_init(&b, 4, 20);
+ clio_style p = clio_style_default();
+ clio_style st = clio_style_default();
+ clio_str txt;
+ clio_str_init(&txt);
+
+ /* type "ab", caret after 'b' (col 4) */
+ clio_str_ins(&txt, 0, "ab", 2);
+ size_t col = clio_readline_paint(&b, 0, "> ", p, &txt, 2, st, 1);
+ CHECK(col == 4, "caret col echo");
+ CHECK(b.cells[0].ch == '>' && b.cells[1].ch == ' ', "prompt painted");
+ CHECK(b.cells[2].ch == 'a' && b.cells[3].ch == 'b', "text painted");
+
+ /* echo off: stars */
+ clio_buf_clear(&b);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 2, st, 0);
+ CHECK(col == 4, "caret col noecho");
+ CHECK(b.cells[2].ch == '*' && b.cells[3].ch == '*', "stars painted");
+
+ /* caret in the middle */
+ clio_str_ins(&txt, 0, "ab", 2);
+ clio_str_ins(&txt, 1, "X", 1);
+ clio_str_ins(&txt, 2, "c", 1);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 2, st, 1);
+ CHECK(col == 4, "caret middle");
+
+ clio_str_free(&txt);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+}
+
+int main(void) {
+ test_utf8();
+ test_buf();
+ test_keydec();
+ test_color_policy();
+ test_fstyle();
+ test_flush_plain();
+ test_flush_diff();
+ test_term_toggles();
+ test_readline();
+ test_readline_long();
+ test_readline_paint();
+
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures == 0 ? 0 : 1;
+}
diff --git a/tests/test_compact.cpp b/tests/test_compact.cpp
new file mode 100644
index 0000000..a8b64f9
--- /dev/null
+++ b/tests/test_compact.cpp
@@ -0,0 +1,517 @@
+#include "compact.h"
+#include "message.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+#define CHECK_EQ_U64(got, want, msg) \
+ do { \
+ checks++; \
+ if ((unsigned long long)(got) != (unsigned long long)(want)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got %llu, want %llu)\n", \
+ __FILE__, __LINE__, (msg), (unsigned long long)(got), \
+ (unsigned long long)(want)); \
+ } \
+ } while (0)
+
+#define CHECK_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (!hay || !strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \
+ __FILE__, __LINE__, (msg), hay ? hay : "(null)", \
+ (needle)); \
+ } \
+ } while (0)
+
+static char *repeat_char(char c, size_t n) {
+ char *s = (char* ) xmalloc(n + 1);
+ memset(s, c, n);
+ s[n] = '\0';
+ return s;
+}
+
+/* text of the first tool-result item of a user message (or ""). */
+static char *trc_text_of(const message *m) {
+ if (!m->is_user() || m->user().parts.empty())
+ return xstrdup("");
+ const user_part &p = m->user().parts[0];
+ const user_tool_result_part *r = std::get_if<user_tool_result_part>(&p);
+ if (!r || r->content.empty())
+ return xstrdup("");
+ const trc_text *t = std::get_if<trc_text>(&r->content[0]);
+ return xstrdup(t ? t->text.c_str() : "");
+}
+
+/* ------------------------------------------------------------------ */
+/* estimate_tokens */
+/* ------------------------------------------------------------------ */
+
+static void test_estimate_tokens(void) {
+ char *body = repeat_char('a', 40);
+ message m = msg_user_text(body);
+ CHECK_EQ_U64(compact_estimate_tokens(&m, 1), 10,
+ "40 bytes -> 10 tokens");
+ free(body);
+
+ CHECK_EQ_U64(compact_estimate_tokens(NULL, 0), 0, "empty -> 0 tokens");
+}
+
+static void test_estimate_tokens_tool_call_args(void) {
+ message m = msg_assistant_tool_call("read_file", "{\"path\":\"a\"}");
+ CHECK_EQ_U64(compact_estimate_tokens(&m, 1), 6,
+ "name(9)+args(12)=21 -> 6 tokens");
+}
+
+/* ------------------------------------------------------------------ */
+/* keep_boundary */
+/* ------------------------------------------------------------------ */
+
+static void test_keep_boundary(void) {
+ message msgs[6] = {
+ msg_user_text("read the file"),
+ msg_assistant_text("ok"),
+ msg_user_tool_result("c1", "read_file", "contents"),
+ msg_assistant_text("here it is"),
+ msg_user_text("now edit it"),
+ msg_assistant_text("done"),
+ };
+ CHECK_EQ_U64(compact_keep_boundary(msgs, 6), 4,
+ "boundary is last user");
+
+ message n[2] = {
+ msg_user_tool_result("c1", "read_file", "contents"),
+ msg_assistant_text("done"),
+ };
+ CHECK_EQ_U64(compact_keep_boundary(n, 2), 2, "none -> keep all");
+
+ CHECK_EQ_U64(compact_keep_boundary(NULL, 0), 0, "empty -> 0");
+}
+
+/* ------------------------------------------------------------------ */
+/* clear_old_tool_results */
+/* ------------------------------------------------------------------ */
+
+static void test_clear_old_tool_results(void) {
+ message msgs[6] = {
+ msg_user_text("read the file"),
+ msg_assistant_text("ok"),
+ msg_user_tool_result("c1", "read_file", "lots of content"),
+ msg_assistant_text("here it is"),
+ msg_user_text("now edit it"),
+ msg_user_tool_result("c2", "write_file", "tail content"),
+ };
+ size_t boundary = compact_keep_boundary(msgs, 6);
+ CHECK_EQ_U64(boundary, 4, "boundary == 4");
+
+ size_t cleared = compact_clear_old_tool_results(msgs, 6, boundary);
+ CHECK_EQ_U64(cleared, 1, "cleared exactly one");
+
+ char *t2 = trc_text_of(&msgs[2]);
+ CHECK(xstreq(t2, CLEARED_PLACEHOLDER), "stale result placeholdered");
+ free(t2);
+
+ char *t5 = trc_text_of(&msgs[5]);
+ CHECK(xstreq(t5, "tail content"), "tail result untouched");
+ free(t5);
+
+ /* name + call preserved on the cleared result */
+ CHECK(msgs[2].is_user(), "still a user message");
+ CHECK(std::holds_alternative<user_tool_result_part>(msgs[2].user().parts[0]),
+ "still a tool result");
+ CHECK(std::get<user_tool_result_part>(msgs[2].user().parts[0]).name ==
+ "read_file",
+ "name preserved");
+ CHECK(std::get<user_tool_result_part>(msgs[2].user().parts[0]).call ==
+ "c1",
+ "call id preserved");
+
+ /* idempotent */
+ CHECK_EQ_U64(compact_clear_old_tool_results(msgs, 6, boundary), 0,
+ "second pass clears nothing");
+}
+
+/* ------------------------------------------------------------------ */
+/* transcript_markdown */
+/* ------------------------------------------------------------------ */
+
+static void test_transcript_markdown(void) {
+ message msgs[4] = {
+ msg_user_text("read the file"),
+ msg_assistant_tool_call("read_file", "{\"path\":\"a.txt\"}"),
+ msg_user_tool_result("c1", "read_file", "line one\nline two"),
+ msg_assistant_text("The file has two lines."),
+ };
+ char *out = compact_transcript_markdown(msgs, 4);
+ CHECK(out && strncmp(out, "# llsh transcript", 15) == 0, "header");
+ CHECK_CONT(out, "## User\n\nread the file", "user block");
+ CHECK_CONT(out, "### Tool call: `read_file`", "tool call heading");
+ CHECK_CONT(out, "```json", "json fence");
+ CHECK_CONT(out, "\"path\"", "json path key");
+ CHECK_CONT(out, "### Tool result: `read_file`", "tool result heading");
+ CHECK_CONT(out, "line one\nline two", "result body");
+ CHECK_CONT(out, "## Assistant\n\nThe file has two lines.", "assistant");
+ free(out);
+}
+
+static void test_transcript_markdown_empty(void) {
+ char *out = compact_transcript_markdown(NULL, 0);
+ CHECK(out && strncmp(out, "# llsh transcript", 15) == 0, "header");
+ CHECK_CONT(out, "_No messages yet._", "placeholder");
+ free(out);
+}
+
+/* ------------------------------------------------------------------ */
+/* recent_file_paths */
+/* ------------------------------------------------------------------ */
+
+static void test_recent_file_paths(void) {
+ strlist out;
+ const char *s1 = "## Goal\ndo a thing\n\n## Files\n- src/foo.rs\n"
+ "* src/bar.rs\n\n## Next steps\n- more";
+ compact_recent_file_paths(s1, &out);
+ CHECK(out.len == 2, "two paths");
+ if (out.len == 2) {
+ CHECK(xstreq(out.items[0], "src/foo.rs"), "path 0");
+ CHECK(xstreq(out.items[1], "src/bar.rs"), "path 1");
+ }
+ sl_free(&out);
+
+ compact_recent_file_paths("## Goal\nnothing here", &out);
+ CHECK(out.len == 0, "no files section -> empty");
+ sl_free(&out);
+
+ const char *s3 = "## Next steps\n- src/foo.rs\n\n## Files\n- src/bar.rs";
+ compact_recent_file_paths(s3, &out);
+ CHECK(out.len == 1, "ignores bullets outside files");
+ if (out.len == 1)
+ CHECK(xstreq(out.items[0], "src/bar.rs"), "only bar.rs");
+ sl_free(&out);
+}
+
+/* ------------------------------------------------------------------ */
+/* compose_history */
+/* ------------------------------------------------------------------ */
+
+static void test_compose_history(void) {
+ message tail[2] = {
+ msg_user_text("now edit it"),
+ msg_assistant_text("done"),
+ };
+ const char *paths[1] = {"src/foo.rs"};
+ const char *contents[1] = {"fn main() {}"};
+ history *h = compact_compose_history("## Goal\nship it", paths, contents,
+ 1, tail, 2);
+ CHECK_EQ_U64(history_len(h), 3, "one body + tail");
+ const message *first = history_at(h, 0);
+ CHECK(first->is_user(), "first is user");
+ CHECK_EQ_U64(first->user().parts.size(), 1, "single text part");
+ const std::string &body =
+ std::get<user_text_part>(first->user().parts[0]).text;
+ CHECK_CONT(body.c_str(), "## Goal", "summary present");
+ CHECK_CONT(body.c_str(), "src/foo.rs", "file path present");
+ CHECK_CONT(body.c_str(), "fn main() {}", "file content present");
+ CHECK(history_at(h, 1)->is_user(), "tail[0] user");
+ CHECK(history_at(h, 2)->is_assistant(), "tail[1] assistant");
+ history_free(h);
+ free(h);
+}
+
+static void test_compose_history_caps(void) {
+ char *big = repeat_char('x', 200000);
+ const char *paths[1] = {"big.log"};
+ const char *contents[1] = {big};
+ history *h =
+ compact_compose_history("summary", paths, contents, 1, NULL, 0);
+ CHECK_EQ_U64(history_len(h), 1, "just the body");
+ const std::string &body =
+ std::get<user_text_part>(history_at(h, 0)->user().parts[0]).text;
+ CHECK(utf8_len(body.c_str()) < 200000, "content capped, not full");
+ char *want = repeat_char('x', PER_FILE_CAP);
+ CHECK_CONT(body.c_str(), want, "first PER_FILE_CAP chars present");
+ free(want);
+ history_free(h);
+ free(h);
+ free(big);
+}
+
+/* ------------------------------------------------------------------ */
+/* transcript (flat) */
+/* ------------------------------------------------------------------ */
+
+static void test_transcript_flat(void) {
+ message msgs[3] = {
+ msg_user_text("read the file"),
+ msg_assistant_text("reading"),
+ msg_user_tool_result("c1", "read_file", "the body"),
+ };
+ char *out = compact_transcript(msgs, 3);
+ CHECK_CONT(out, "User: read the file", "user line");
+ CHECK_CONT(out, "Assistant: reading", "assistant line");
+ CHECK_CONT(out, "ToolResult(read_file): the body", "tool result line");
+ free(out);
+}
+
+/* ------------------------------------------------------------------ */
+/* compact_at / clear_at */
+/* ------------------------------------------------------------------ */
+
+static void test_thresholds(void) {
+ compact_config c;
+ compact_config_from(1000, 0.75, 0.5, &c);
+ unsigned long v;
+ CHECK(compact_at(&c, &v), "compact enabled");
+ CHECK_EQ_U64(v, 750, "compact_at == 750");
+ CHECK(clear_at(&c, &v), "clear enabled");
+ CHECK_EQ_U64(v, 500, "clear_at == 500");
+
+ compact_config d;
+ compact_config_from(1000, 0.0, 0.0, &d);
+ CHECK(!compact_at(&d, &v), "compact disabled");
+ CHECK(!clear_at(&d, &v), "clear disabled");
+}
+
+/* ------------------------------------------------------------------ */
+/* message model */
+/* ------------------------------------------------------------------ */
+
+static void test_message_model(void) {
+ message m = msg_user_tool_result("c9", "grep", "match");
+ CHECK(m.is_user(), "role user");
+ CHECK_EQ_U64(m.user().parts.size(), 1, "one part");
+ CHECK(std::holds_alternative<user_tool_result_part>(m.user().parts[0]),
+ "tool result kind");
+ CHECK(std::get<user_tool_result_part>(m.user().parts[0]).name == "grep",
+ "name");
+ CHECK(std::get<user_tool_result_part>(m.user().parts[0]).call == "c9",
+ "call");
+ CHECK(std::get<trc_text>(
+ std::get<user_tool_result_part>(m.user().parts[0]).content[0])
+ .text == "match",
+ "content");
+
+ /* copy round-trip */
+ message orig = msg_assistant_tool_call("write_file", "{\"path\":\"a\"}");
+ message copy = orig;
+ CHECK(copy.is_assistant(), "copy role");
+ CHECK_EQ_U64(copy.assistant().parts.size(), 1, "copy len");
+ CHECK(std::get<asst_tool_call_part>(copy.assistant().parts[0]).name ==
+ "write_file",
+ "copy name");
+ CHECK(std::get<asst_tool_call_part>(copy.assistant().parts[0]).args ==
+ "{\"path\":\"a\"}",
+ "copy args");
+ /* independent storage: mutating orig does not affect copy */
+ std::get<asst_tool_call_part>(orig.assistant().parts[0]).name = "other";
+ CHECK(std::get<asst_tool_call_part>(copy.assistant().parts[0]).name ==
+ "write_file",
+ "copy independent");
+
+ /* multi-part assistant message */
+ message multi = msg_assistant_text("thinking...");
+ msg_push_assistant_tool_call(multi, "run_shell", "echo hi");
+ msg_push_assistant_reasoning(multi, "because");
+ CHECK_EQ_U64(multi.assistant().parts.size(), 3, "three parts");
+ CHECK(std::holds_alternative<asst_text_part>(multi.assistant().parts[0]),
+ "part0 text");
+ CHECK(std::holds_alternative<asst_tool_call_part>(
+ multi.assistant().parts[1]),
+ "part1 tool call");
+ CHECK(std::holds_alternative<asst_reasoning_part>(
+ multi.assistant().parts[2]),
+ "part2 reasoning");
+
+ /* history push / take */
+ history h;
+ history_push(&h, msg_user_text("one"));
+ history_push(&h, msg_user_text("two"));
+ history_push(&h, msg_user_text("three"));
+ CHECK_EQ_U64(history_len(&h), 3, "len 3");
+ message mid = history_take(&h, 1);
+ CHECK(std::get<user_text_part>(mid.user().parts[0]).text == "two",
+ "take returns elem 1");
+ CHECK_EQ_U64(history_len(&h), 2, "len 2 after take");
+ CHECK(std::get<user_text_part>(history_at(&h, 0)->user().parts[0]).text ==
+ "one",
+ "elem0 intact");
+ CHECK(std::get<user_text_part>(history_at(&h, 1)->user().parts[0]).text ==
+ "three",
+ "elem1 shifted");
+ /* stack history: RAII destructor cleans up at scope exit */
+
+ /* preamble sanity */
+ const char *p = compact_summarizer_preamble();
+ CHECK_CONT(p, "## Goal", "preamble goal section");
+ CHECK_CONT(p, "## Files", "preamble files section");
+ CHECK_CONT(p, "under 800 words", "preamble word limit");
+}
+
+/* ------------------------------------------------------------------ */
+/* rubric (SelfCompact) */
+/* ------------------------------------------------------------------ */
+
+static void test_rubric_parse_valid(void) {
+ const char *answer =
+ "C1: Y -- the last message ends with a finished tool result\n"
+ "C2: Y -- 1. fact one \"x\"\n"
+ "C3: Y -- a new URL was obtained\n"
+ "N1: N -- each recent call returned new content\n";
+ compact_rubric r;
+ char *err = NULL;
+ CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok");
+ CHECK(err == NULL, "no err");
+ CHECK_EQ_U64(r.c1, 1, "c1 Y");
+ CHECK_EQ_U64(r.c2, 1, "c2 Y");
+ CHECK_EQ_U64(r.c3, 1, "c3 Y");
+ CHECK_EQ_U64(r.n1, 0, "n1 N");
+ CHECK(compact_rubric_fire(&r), "fires when c1&c2&c3 & !n1");
+}
+
+static void test_rubric_parse_n(void) {
+ const char *answer =
+ "C1: N -- still mid-thought\n"
+ "C2: Y -- facts\n"
+ "C3: Y -- progress\n"
+ "N1: N -- fresh results\n";
+ compact_rubric r;
+ char *err = NULL;
+ CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok");
+ CHECK_EQ_U64(r.c1, 0, "c1 N");
+ CHECK(!compact_rubric_fire(&r), "does not fire with c1 N");
+}
+
+static void test_rubric_parse_stuck(void) {
+ const char *answer =
+ "C1: Y -- closed\n"
+ "C2: Y -- facts\n"
+ "C3: Y -- progress\n"
+ "N1: Y -- three of last four calls repeated\n";
+ compact_rubric r;
+ char *err = NULL;
+ CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok");
+ CHECK_EQ_U64(r.n1, 1, "n1 Y");
+ CHECK(!compact_rubric_fire(&r), "does not fire when stuck");
+}
+
+static void test_rubric_parse_missing(void) {
+ const char *answer =
+ "C1: Y -- closed\n"
+ "C2: Y -- facts\n"
+ "C3: Y -- progress\n";
+ compact_rubric r;
+ char *err = NULL;
+ CHECK(compact_rubric_parse(answer, &r, &err) != 0, "missing N1 -> error");
+ CHECK(err != NULL, "err set");
+ free(err);
+}
+
+static void test_rubric_parse_malformed(void) {
+ const char *answer =
+ "C1: MAYBE -- closed\n"
+ "C2: Y -- facts\n"
+ "C3: Y -- progress\n"
+ "N1: N -- fresh\n";
+ compact_rubric r;
+ char *err = NULL;
+ CHECK(compact_rubric_parse(answer, &r, &err) != 0, "bad token -> error");
+ CHECK(err != NULL, "err set");
+ free(err);
+}
+
+static void test_rubric_y_without_evidence_is_n(void) {
+ /* A Y with no evidence on the line is downgraded to N (the paper's rule). */
+ const char *answer =
+ "C1: Y\n"
+ "C2: Y -- facts\n"
+ "C3: Y -- progress\n"
+ "N1: N -- fresh\n";
+ compact_rubric r;
+ char *err = NULL;
+ CHECK(compact_rubric_parse(answer, &r, &err) == 0, "parse ok");
+ CHECK_EQ_U64(r.c1, 0, "Y without evidence downgraded to N");
+ CHECK(!compact_rubric_fire(&r), "does not fire");
+}
+
+static void test_rubric_prompt_and_preamble(void) {
+ const char *p = compact_rubric_prompt();
+ CHECK_CONT(p, "C1", "prompt has C1");
+ CHECK_CONT(p, "C2", "prompt has C2");
+ CHECK_CONT(p, "C3", "prompt has C3");
+ CHECK_CONT(p, "N1", "prompt has N1");
+ CHECK_CONT(p, "C1: Y/N -- <evidence>", "prompt output format");
+
+ const char *pre = compact_rubric_preamble();
+ CHECK_CONT(pre, "judge", "preamble is a judge role");
+}
+
+static void test_rubric_due(void) {
+ /* all gates satisfied */
+ CHECK(compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 0,
+ RUBRIC_DEFAULT_MAX_SUMMARIES,
+ RUBRIC_MIN_ROUNDS_SINCE_PROBE),
+ "due at exact thresholds");
+ /* iteration too low */
+ CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION - 1, RUBRIC_MIN_TOKENS, 0,
+ RUBRIC_DEFAULT_MAX_SUMMARIES,
+ RUBRIC_MIN_ROUNDS_SINCE_PROBE),
+ "not due: iteration");
+ /* tokens too low */
+ CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS - 1, 0,
+ RUBRIC_DEFAULT_MAX_SUMMARIES,
+ RUBRIC_MIN_ROUNDS_SINCE_PROBE),
+ "not due: tokens");
+ /* cap reached */
+ CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 1,
+ RUBRIC_DEFAULT_MAX_SUMMARIES,
+ RUBRIC_MIN_ROUNDS_SINCE_PROBE),
+ "not due: cap");
+ /* rounds since probe too low */
+ CHECK(!compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 0,
+ RUBRIC_DEFAULT_MAX_SUMMARIES,
+ RUBRIC_MIN_ROUNDS_SINCE_PROBE - 1),
+ "not due: rounds");
+ /* unlimited cap (0) */
+ CHECK(compact_rubric_due(RUBRIC_MIN_ITERATION, RUBRIC_MIN_TOKENS, 5, 0,
+ RUBRIC_MIN_ROUNDS_SINCE_PROBE),
+ "due: unlimited cap");
+}
+
+int main(void) {
+ test_estimate_tokens();
+ test_estimate_tokens_tool_call_args();
+ test_keep_boundary();
+ test_clear_old_tool_results();
+ test_transcript_markdown();
+ test_transcript_markdown_empty();
+ test_recent_file_paths();
+ test_compose_history();
+ test_compose_history_caps();
+ test_transcript_flat();
+ test_thresholds();
+ test_message_model();
+ test_rubric_parse_valid();
+ test_rubric_parse_n();
+ test_rubric_parse_stuck();
+ test_rubric_parse_missing();
+ test_rubric_parse_malformed();
+ test_rubric_y_without_evidence_is_n();
+ test_rubric_prompt_and_preamble();
+ test_rubric_due();
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
new file mode 100644
index 0000000..c2b5e2b
--- /dev/null
+++ b/tests/test_config.cpp
@@ -0,0 +1,654 @@
+#include "config.h"
+
+#include <math.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+#define CHECK_EQ_STR(a, b, msg) \
+ do { \
+ checks++; \
+ const char *_a = (a), *_b = (b); \
+ if (!_a || (_b && strcmp(_a, _b) != 0)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \
+ __FILE__, __LINE__, (msg), _a ? _a : "(null)", \
+ _b ? _b : "(null)"); \
+ } \
+ } while (0)
+
+static cli mkcli(void) {
+ cli c;
+ memset(&c, 0, sizeof c);
+ return c;
+}
+
+static void freecfg(config *c) {
+ config_free(c);
+}
+
+/* ------------------------------------------------------------------ */
+/* Provider parsing */
+/* ------------------------------------------------------------------ */
+
+static void test_provider_parsing(void) {
+ char *err = NULL;
+ CHECK(provider_parse("openai", &err) == PROV_OPENAI, "openai");
+ CHECK(provider_parse("OpenAI-compatible", &err) == PROV_OPENAI,
+ "OpenAI-compatible");
+ CHECK(provider_parse("openai_compatible", &err) == PROV_OPENAI,
+ "openai_compatible");
+ CHECK(provider_parse("Anthropic", &err) == PROV_ANTHROPIC, "Anthropic");
+ CHECK(provider_parse("claude", &err) == PROV_ANTHROPIC, "claude");
+ CHECK(provider_parse("ollama", &err) == PROV_OLLAMA, "ollama");
+
+ err = NULL;
+ provider_kind k = provider_parse("nope", &err);
+ CHECK(k == PROV_OPENAI && err && strstr(err, "nope") &&
+ strstr(err, "unknown provider"),
+ "unknown provider error");
+ free(err);
+
+ CHECK(strcmp(provider_default_model(PROV_OPENAI), "gpt-5.2") == 0,
+ "default openai model");
+ CHECK(strcmp(provider_default_model(PROV_ANTHROPIC), "claude-sonnet-4-6") ==
+ 0,
+ "default anthropic model");
+ CHECK(strcmp(provider_default_model(PROV_OLLAMA), "llama3.2") == 0,
+ "default ollama model");
+}
+
+/* ------------------------------------------------------------------ */
+/* CLI parsing */
+/* ------------------------------------------------------------------ */
+
+static void test_cli_parse_basic(void) {
+ cli c;
+ char *err = NULL;
+ const char *a1 = "--provider";
+ const char *a2 = "ollama";
+ const char *a3 = "--model";
+ const char *a4 = "llama3.2";
+ const char *a5 = "hello world";
+ const char *argv[] = {
+ "llsh", a1, a2, a3, a4, a5
+ };
+ int r = cli_parse(6, (char **)argv, &c, &err);
+ CHECK(r == 0, "parse ok");
+ CHECK_EQ_STR(c.provider, "ollama", "provider");
+ CHECK_EQ_STR(c.model, "llama3.2", "model");
+ CHECK_EQ_STR(c.prompt, "hello world", "prompt positional");
+ CHECK(!c.debug, "debug off by default");
+ cli_free(&c);
+ free(err);
+
+ /* -C and --verbose and numeric flags */
+ const char *b1 = "-C";
+ const char *b2 = "/tmp";
+ const char *b3 = "--verbose";
+ const char *b4 = "--max-turns";
+ const char *b5 = "0";
+ const char *b6 = "--max-retries";
+ const char *b7 = "2";
+ const char *b8 = "--debug";
+ const char *b9 = "--allow-mcp";
+ const char *argv2[] = {
+ "llsh", b1, b2, b3, b4, b5, b6, b7, b8, b9
+ };
+ r = cli_parse(10, (char **)argv2, &c, &err);
+ CHECK(r == 0, "parse -C/--verbose ok");
+ CHECK_EQ_STR(c.cwd, "/tmp", "cwd");
+ CHECK(c.verbose, "verbose flag");
+ CHECK(c.debug, "debug flag");
+ CHECK(c.allow_mcp, "allow-mcp trust opt-in");
+ CHECK(c.has_max_turns && c.max_turns == 0, "max-turns 0 accepted");
+ CHECK(c.has_max_retries && c.max_retries == 2, "max-retries");
+ cli_free(&c);
+ free(err);
+
+ /* unknown option */
+ const char *u1 = "--frobnicate";
+ const char *argv3[] = {
+ "llsh", u1
+ };
+ r = cli_parse(2, (char **)argv3, &c, &err);
+ CHECK(r == -1 && err, "unknown option errors");
+ free(err);
+ cli_free(&c);
+
+ /* two positionals */
+ const char *p1 = "one";
+ const char *p2 = "two";
+ const char *argv4[] = {
+ "llsh", p1, p2
+ };
+ r = cli_parse(3, (char **)argv4, &c, &err);
+ CHECK(r == -1 && err && strstr(err, "unexpected argument"),
+ "two positionals error");
+ free(err);
+ cli_free(&c);
+}
+
+static void test_cli_shell_policy_options(void) {
+ cli c;
+ char *err = NULL;
+ const char *argv[] = {"llsh", "--shell-allow", "git", "--shell-allow",
+ "rg", "--shell-deny", "rm", "--confirm-destructive"};
+ CHECK(cli_parse(8, (char **)argv, &c, &err) == 0,
+ "parse shell policy options");
+ CHECK(c.shell_allowlist.len == 2, "repeatable shell allowlist");
+ CHECK(c.shell_denylist.len == 1, "shell denylist");
+ CHECK(c.confirm_destructive, "destructive confirmation enabled");
+ config resolved;
+ CHECK(config_merge(&c, &resolved, &err) == 0,
+ "merge shell policy into runtime config");
+ CHECK(resolved.shell_allowlist.len == 2 &&
+ strcmp(resolved.shell_allowlist.items[0], "git") == 0,
+ "merged allowlist preserved");
+ CHECK(resolved.shell_denylist.len == 1 &&
+ strcmp(resolved.shell_denylist.items[0], "rm") == 0,
+ "merged denylist preserved");
+ CHECK(resolved.confirm_destructive,
+ "merged destructive confirmation enabled");
+ config_free(&resolved);
+ cli_free(&c);
+ free(err);
+}
+
+static void test_cli_parse_rejects_negative_ulong(void) {
+ /* `--max-turns -1` used to parse as ULONG_MAX via strtoull; it must error. */
+ cli c;
+ char *err = NULL;
+ const char *a1 = "--max-turns";
+ const char *a2 = "-1";
+ const char *argv[] = {"llsh", a1, a2};
+ int r = cli_parse(3, (char **)argv, &c, &err);
+ CHECK(r == -1, "negative max-turns rejected");
+ CHECK(err && strstr(err, "invalid value") != NULL, "invalid-value message");
+ free(err);
+ cli_free(&c);
+
+ /* A genuinely huge value that overflows unsigned long must also error. */
+ const char *h1 = "--max-turns";
+ const char *h2 = "99999999999999999999";
+ const char *argv2[] = {"llsh", h1, h2};
+ r = cli_parse(3, (char **)argv2, &c, &err);
+ CHECK(r == -1, "overflowing max-turns rejected");
+ free(err);
+ cli_free(&c);
+}
+
+static void test_cli_parse_fraction_thresholds(void) {
+ cli c;
+ char *err = NULL;
+ const char *good[] = {"llsh", "--compact-threshold", "0.75",
+ "--clear-threshold", "0"};
+ CHECK(cli_parse(5, (char **)good, &c, &err) == 0,
+ "valid fractions accepted");
+ CHECK(c.has_compact_threshold && c.compact_threshold == 0.75,
+ "compact fraction parsed");
+ CHECK(c.has_clear_threshold && c.clear_threshold == 0,
+ "zero disables clear fraction");
+ cli_free(&c);
+ free(err);
+ const char *bad_values[] = {"xyz", "0.75junk", "nan", "inf", "1.01"};
+ for (size_t i = 0; i < sizeof bad_values / sizeof bad_values[0]; i++) {
+ const char *args[] = {"llsh", "--compact-threshold", bad_values[i]};
+ err = NULL;
+ CHECK(cli_parse(3, (char **)args, &c, &err) == -1,
+ "malformed/out-of-range fraction rejected");
+ CHECK(err != NULL, "fraction rejection explains error");
+ free(err);
+ cli_free(&c);
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* File config loading */
+/* ------------------------------------------------------------------ */
+
+/* ------------------------------------------------------------------ */
+/* Merge */
+/* ------------------------------------------------------------------ */
+
+static void test_merge_cli_values(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("anthropic");
+ c.model = xstrdup("cli-model");
+ c.api_key = xstrdup("cli-key");
+
+ config cfg;
+ char *err = NULL;
+ int r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "merge ok");
+ CHECK(cfg.provider == PROV_ANTHROPIC, "cli provider");
+ CHECK_EQ_STR(cfg.model, "cli-model", "cli model");
+ CHECK_EQ_STR(cfg.api_key, "cli-key", "cli key");
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_merge_defaults(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+
+ config cfg;
+ char *err = NULL;
+ int r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "merge ok");
+ CHECK(cfg.provider == PROV_OLLAMA, "provider from cli");
+ CHECK_EQ_STR(cfg.model, "llama3.2", "provider default model");
+ CHECK(cfg.api_key == NULL, "no key for ollama");
+ CHECK(cfg.max_turns == 0, "max_turns unbounded by default");
+ CHECK(cfg.preamble && cfg.preamble[0] != '\0', "built-in preamble");
+ CHECK(cfg.max_retries == 3, "default max_retries");
+ CHECK(cfg.context_limit == 0, "no built-in window");
+ CHECK(cfg.compact_threshold > 0.74 && cfg.compact_threshold < 0.76,
+ "default compact_threshold");
+ CHECK(cfg.clear_threshold > 0.49 && cfg.clear_threshold < 0.51,
+ "default clear_threshold");
+ CHECK(!cfg.verbose, "verbose off");
+ CHECK(cfg.skill_index_cap == 50, "default skill cap");
+ CHECK(cfg.shell_timeout_secs == 120, "default shell timeout");
+ CHECK(cfg.max_output_chars == 16000, "default max output");
+ CHECK(cfg.max_file_bytes == 2000000, "default max file bytes");
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_merge_api_key_rules(void) {
+ /* openai without base_url or key: the merge defers the check so a
+ * models.json preset can still supply the key */
+ cli c = mkcli();
+ c.provider = xstrdup("openai");
+ config cfg;
+ char *err = NULL;
+ int r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "merge defers key check");
+ r = config_require_key_or_base(&cfg, &err);
+ CHECK(r == -1 && err && strstr(err, "no API key"),
+ "openai needs key or base_url");
+ free(err);
+ config_free(&cfg);
+ cli_free(&c);
+
+ /* base_url is enough */
+ c = mkcli();
+ c.provider = xstrdup("openai");
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ err = NULL;
+ r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "base_url ok");
+ CHECK_EQ_STR(cfg.base_url, "http://localhost:8080/v1", "base_url kept");
+ CHECK(cfg.api_key == NULL, "no key needed");
+ freecfg(&cfg);
+ cli_free(&c);
+
+ /* anthropic with key is fine */
+ c = mkcli();
+ c.provider = xstrdup("anthropic");
+ c.api_key = xstrdup("k");
+ err = NULL;
+ r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "anthropic+key ok");
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_merge_max_turns_and_retries(void) {
+ /* max_turns 0 is unbounded and stays 0 */
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.max_turns = 0;
+ c.has_max_turns = true;
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.max_turns == 0, "max_turns 0 stays 0 (unbounded)");
+ freecfg(&cfg);
+ cli_free(&c);
+
+ /* explicit retries are honored */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.max_retries = 5;
+ c.has_max_retries = true;
+ err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.max_retries == 5, "retries honored");
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_merge_compaction(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.context_limit = 4000;
+ c.has_context_limit = true;
+ c.compact_threshold = 0.9;
+ c.has_compact_threshold = true;
+ c.clear_threshold = 0.25;
+ c.has_clear_threshold = true;
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.context_limit == 4000, "context_limit");
+ CHECK(fabs(cfg.compact_threshold - 0.9) < 1e-9, "compact");
+ CHECK(fabs(cfg.clear_threshold - 0.25) < 1e-9, "clear");
+ freecfg(&cfg);
+ cli_free(&c);
+
+ /* threshold out of range */
+ int r;
+ double bad[] = {2.0, -0.1, 1.1, NAN, INFINITY};
+ for (size_t i = 0; i < sizeof bad / sizeof *bad; i++) {
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.compact_threshold = bad[i];
+ c.has_compact_threshold = true;
+ err = NULL;
+ config cfg2;
+ r = config_merge(&c, &cfg2, &err);
+ CHECK(r == -1 && err && strstr(err, "compact_threshold"),
+ "bad compact_threshold rejected");
+ free(err);
+ config_free(&cfg2);
+ cli_free(&c);
+ }
+ double badc[] = {-0.1, 1.5, NAN};
+ for (size_t i = 0; i < sizeof badc / sizeof *badc; i++) {
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.clear_threshold = badc[i];
+ c.has_clear_threshold = true;
+ err = NULL;
+ config cfg2;
+ r = config_merge(&c, &cfg2, &err);
+ CHECK(r == -1 && err && strstr(err, "clear_threshold"),
+ "bad clear_threshold rejected");
+ free(err);
+ config_free(&cfg2);
+ cli_free(&c);
+ }
+
+ /* clear < compact required when a window is known */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.context_limit = 1000;
+ c.has_context_limit = true;
+ c.clear_threshold = 0.8;
+ c.has_clear_threshold = true;
+ c.compact_threshold = 0.5;
+ c.has_compact_threshold = true;
+ err = NULL;
+ config cfg2;
+ r = config_merge(&c, &cfg2, &err);
+ CHECK(r == -1 && err && strstr(err, "clear_threshold"),
+ "clear >= compact rejected");
+ free(err);
+ config_free(&cfg2);
+ cli_free(&c);
+
+ /* disabled tier (0.0) imposes no ordering constraint */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.context_limit = 1000;
+ c.has_context_limit = true;
+ c.clear_threshold = 0.0;
+ c.has_clear_threshold = true;
+ c.compact_threshold = 0.5;
+ c.has_compact_threshold = true;
+ err = NULL;
+ r = config_merge(&c, &cfg2, &err);
+ CHECK(r == 0, "clear=0 disables ordering");
+ freecfg(&cfg2);
+ cli_free(&c);
+
+ /* 0 thresholds are valid (tiers off) */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.compact_threshold = 0.0;
+ c.has_compact_threshold = true;
+ c.clear_threshold = 0.0;
+ c.has_clear_threshold = true;
+ err = NULL;
+ r = config_merge(&c, &cfg2, &err);
+ CHECK(r == 0, "0/0 thresholds ok");
+ freecfg(&cfg2);
+ cli_free(&c);
+}
+
+static void test_merge_lower_bounds_and_verbose(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.skill_index_cap = 0;
+ c.has_skill_index_cap = true;
+ c.shell_timeout_secs = 0;
+ c.has_shell_timeout_secs = true;
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.skill_index_cap == 1, "skill cap floor 1");
+ CHECK(cfg.shell_timeout_secs == 1, "shell timeout floor 1");
+ freecfg(&cfg);
+ cli_free(&c);
+
+ /* verbose from CLI */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.verbose = true;
+ err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.verbose, "cli verbose applied");
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+/* ------------------------------------------------------------------ */
+/* Presets */
+/* ------------------------------------------------------------------ */
+
+static models_file mkmodels(const char *def, const char *prov,
+ const char *model, const char *base_url,
+ const char *key, unsigned long mt,
+ unsigned long ctx) {
+ models_file m;
+ memset(&m, 0, sizeof m);
+ m.default_name = xstrdup(def);
+ m.presets = (model_preset* ) xmalloc(sizeof *m.presets);
+ memset(m.presets, 0, sizeof *m.presets);
+ m.presets[0].name = xstrdup(def);
+ m.presets[0].provider = prov ? xstrdup(prov) : NULL;
+ m.presets[0].model = model ? xstrdup(model) : NULL;
+ m.presets[0].base_url = base_url ? xstrdup(base_url) : NULL;
+ m.presets[0].api_key = key ? xstrdup(key) : NULL;
+ if (mt) {
+ m.presets[0].max_turns = mt;
+ m.presets[0].has_max_turns = true;
+ }
+ if (ctx) {
+ m.presets[0].context_limit = ctx;
+ m.presets[0].has_context_limit = true;
+ }
+ m.preset_count = 1;
+ return m;
+}
+
+static void test_preset_overrides_defaults(void) {
+ /* CLI base_url (with an unparseable-ish provider) lets the preset supply
+ * provider/model/key; every preset field applies over the base. */
+ cli c = mkcli();
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+
+ models_file m = mkmodels("local", "openai", "llm", NULL, "pk", 7, 0);
+ char *name = config_apply_models_preset(&cfg, &m, &c, &err);
+ CHECK(name && strcmp(name, "local") == 0, "preset applied");
+ CHECK(cfg.provider == PROV_OPENAI, "preset provider");
+ CHECK_EQ_STR(cfg.model, "llm", "preset model");
+ CHECK_EQ_STR(cfg.base_url, "http://localhost:8080/v1", "base_url kept");
+ CHECK_EQ_STR(cfg.api_key, "pk", "preset api_key");
+ CHECK(cfg.max_turns == 7, "preset max_turns");
+ free(name);
+ models_file_free(&m);
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_preset_cli_flag_wins(void) {
+ /* CLI --model / --provider are authoritative: the preset must not clobber
+ * them (it may still fill fields the CLI left unset). */
+ cli c = mkcli();
+ c.model = xstrdup("cli-model");
+ c.provider = xstrdup("anthropic");
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+
+ models_file m = mkmodels("local", "openai", "preset-model",
+ "http://preset.example/v1", NULL, 0, 0);
+ char *name = config_apply_models_preset(&cfg, &m, &c, &err);
+ CHECK(name, "preset applied");
+ CHECK(cfg.provider == PROV_ANTHROPIC, "CLI provider wins");
+ CHECK_EQ_STR(cfg.model, "cli-model", "CLI model wins");
+ CHECK_EQ_STR(cfg.base_url, "http://localhost:8080/v1", "CLI base_url wins");
+ free(name);
+ models_file_free(&m);
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_preset_errors(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+
+ models_file empty;
+ memset(&empty, 0, sizeof empty);
+ char *name = config_apply_models_preset(&cfg, &empty, &c, &err);
+ CHECK(!name && err && strstr(err, "no `default` preset"),
+ "no default preset error");
+ free(err);
+ err = NULL;
+
+ models_file ghost = mkmodels("ghost", NULL, NULL, NULL, NULL, 0, 0);
+ free(ghost.presets[0].name);
+ ghost.presets[0].name = xstrdup("other");
+ name = config_apply_models_preset(&cfg, &ghost, &c, &err);
+ CHECK(!name && err && strstr(err, "not in presets"),
+ "default not in presets error");
+ free(err);
+ models_file_free(&ghost);
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_preset_supplies_api_key(void) {
+ /* Base is valid via base_url but has no key; the preset fills the key
+ * (and the model) for a key-requiring provider. */
+ cli c = mkcli();
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ CHECK(cfg.api_key == NULL, "no key before preset");
+
+ models_file m = mkmodels("cloud", "openai", "cm", NULL, "pk", 0, 0);
+ char *name = config_apply_models_preset(&cfg, &m, &c, &err);
+ CHECK(name, "preset applied");
+ CHECK(cfg.provider == PROV_OPENAI, "preset provider");
+ CHECK_EQ_STR(cfg.api_key, "pk", "preset supplies api_key");
+ CHECK_EQ_STR(cfg.model, "cm", "preset model");
+ free(name);
+ models_file_free(&m);
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+static void test_preset_context_limit(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ config cfg;
+ char *err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ models_file m = mkmodels("small", "openai", "sm", NULL, "k", 0, 8000);
+ char *name = config_apply_models_preset(&cfg, &m, &c, &err);
+ CHECK(name && cfg.context_limit == 8000, "preset context_limit applied");
+ free(name);
+ models_file_free(&m);
+ freecfg(&cfg);
+ cli_free(&c);
+
+ /* explicit CLI --context-limit wins over the preset */
+ c = mkcli();
+ c.provider = xstrdup("ollama");
+ c.base_url = xstrdup("http://localhost:8080/v1");
+ c.context_limit = 16000;
+ c.has_context_limit = true;
+ err = NULL;
+ CHECK(config_merge(&c, &cfg, &err) == 0, "merge ok");
+ m = mkmodels("small", "openai", "sm", NULL, "k", 0, 8000);
+ name = config_apply_models_preset(&cfg, &m, &c, &err);
+ CHECK(name && cfg.context_limit == 16000, "CLI context_limit wins");
+ free(name);
+ models_file_free(&m);
+ freecfg(&cfg);
+ cli_free(&c);
+}
+
+
+static void test_merge_openai_requires_key(void) {
+ cli c = mkcli();
+ c.provider = xstrdup("openai");
+ config cfg;
+ char *err = NULL;
+ int r = config_merge(&c, &cfg, &err);
+ CHECK(r == 0, "merge ok without key (preset may supply it)");
+ r = config_require_key_or_base(&cfg, &err);
+ CHECK(r == -1 && err && strstr(err, "no API key"),
+ "openai without key/base_url rejected");
+ free(err);
+ config_free(&cfg);
+ cli_free(&c);
+}
+
+int main(void) {
+ test_provider_parsing();
+ test_cli_parse_basic();
+ test_cli_shell_policy_options();
+ test_cli_parse_rejects_negative_ulong();
+ test_cli_parse_fraction_thresholds();
+ test_merge_cli_values();
+ test_merge_defaults();
+ test_merge_api_key_rules();
+ test_merge_max_turns_and_retries();
+ test_merge_compaction();
+ test_merge_lower_bounds_and_verbose();
+ test_preset_overrides_defaults();
+ test_preset_cli_flag_wins();
+ test_preset_errors();
+ test_preset_supplies_api_key();
+ test_preset_context_limit();
+ test_merge_openai_requires_key();
+ printf("test_config: %d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_dotagents.c b/tests/test_dotagents.c
new file mode 100644
index 0000000..af51506
--- /dev/null
+++ b/tests/test_dotagents.c
@@ -0,0 +1,618 @@
+/* test_dotagents.c — standalone test suite for dotagents.h (the .agents
+ * protocol layer). Zero dependencies: compiles to a single TU with no
+ * linked libraries (frontmatter.h provides everything it needs). */
+#include "dotagents.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+static char *tmpdir(const char *tag) {
+ char path[256];
+ snprintf(path, sizeof path, "/tmp/llshc-da-%s-%d", tag, (int)getpid());
+ /* rm -rf */
+ char cmd[300];
+ snprintf(cmd, sizeof cmd, "rm -rf '%s'", path);
+ if (system(cmd) != 0) {}
+ /* mkdir -p */
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s'", path);
+ if (system(cmd) != 0)
+ exit(1);
+ return fm_strdup(path);
+}
+
+static void wfile_bin(const char *dir, const char *rel, const char *content) {
+ char path[512];
+ snprintf(path, sizeof path, "%s/%s", dir, rel);
+ char *slash = strrchr(path, '/');
+ if (slash) {
+ char dirpart[512];
+ size_t n = (size_t)(slash - path);
+ memcpy(dirpart, path, n);
+ dirpart[n] = '\0';
+ char cmd[600];
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s'", dirpart);
+ if (system(cmd) != 0)
+ exit(1);
+ }
+ FILE *f = fopen(path, "w");
+ if (!f)
+ exit(1);
+ fputs(content, f);
+ fclose(f);
+}
+
+static int count_warns(const dotagents *d, const char *needle) {
+ int n = 0;
+ for (size_t i = 0; i < d->warnings.len; i++)
+ if (strstr(d->warnings.items[i], needle))
+ n++;
+ return n;
+}
+
+/* ------------------------------------------------------------------ */
+/* Layer tests */
+/* ------------------------------------------------------------------ */
+
+static void test_loads_global_only(void) {
+ char *ws = tmpdir("ws1");
+ char *global = tmpdir("g1");
+ wfile_bin(global, "agents.md", "global guidelines");
+ wfile_bin(global, "skills/g1/skill.md",
+ "---\nid: g1\nname: G1\ndescription: global skill\n---\ndo g1");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.found_count == 1, "one layer found");
+ CHECK(d.found_layers[0] == LAYER_GLOBAL, "layer is global");
+ CHECK(d.agents_md && strcmp(d.agents_md, "global guidelines") == 0,
+ "agents_md");
+ CHECK(d.skill_count == 1, "one skill");
+ CHECK(strcmp(d.skills[0].id, "g1") == 0, "skill id");
+ CHECK(strcmp(d.skills[0].body, "do g1") == 0, "skill body");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_md_filenames_case_insensitive(void) {
+ char *ws = tmpdir("ws-cs");
+ wfile_bin(ws, "AGENTS.MD", "workspace guidelines");
+ wfile_bin(ws, "System-Prompt.md", "the system prompt");
+ wfile_bin(ws, "skills/UPPER/SKILL.md",
+ "---\nid: upper\nname: Upper\ndescription: caps skill\n---\ndo "
+ "upper");
+ wfile_bin(ws, "agents/REVIEWER/Agent.md",
+ "---\nid: reviewer\nname: Reviewer\n---\nYou review.");
+ wfile_bin(ws, "tasks/daily/Task.MD",
+ "---\nid: daily\nname: Daily\nenabled: true\n---\nRun daily.");
+ wfile_bin(ws, "memories/note.MD",
+ "---\nid: note\ntitle: Note\n---\nbody");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 1, "workspace found");
+ CHECK(d.agents_md && strcmp(d.agents_md, "workspace guidelines") == 0,
+ "AGENTS.MD");
+ CHECK(d.system_prompt && strcmp(d.system_prompt, "the system prompt") == 0,
+ "System-Prompt.md");
+ CHECK(d.skill_count == 1 && strcmp(d.skills[0].id, "upper") == 0,
+ "SKILL.md");
+ CHECK(d.sub_agent_count == 1 &&
+ strcmp(d.sub_agents[0].id, "reviewer") == 0,
+ "Agent.md");
+ CHECK(d.task_count == 1 && strcmp(d.tasks[0].id, "daily") == 0,
+ "Task.MD");
+ CHECK(d.memory_count == 1 && strcmp(d.memories[0].id, "note") == 0,
+ "note.MD");
+ CHECK(d.warnings.len == 0, "no case warnings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_workspace_wins(void) {
+ char *ws = tmpdir("ws2");
+ char *global = tmpdir("g2");
+ wfile_bin(global, "agents.md", "global guidelines");
+ wfile_bin(global, "skills/review/skill.md",
+ "---\nid: review\nname: Review G\ndescription: global\n---\n"
+ "global body");
+ wfile_bin(global, "memories/m.md",
+ "---\nid: m\ntitle: M Global\nimportance: low\ntags: a\n---\ng");
+ wfile_bin(ws, "agents.md", "workspace guidelines");
+ wfile_bin(ws, "skills/review/skill.md",
+ "---\nid: review\nname: Review W\ndescription: workspace\n---\n"
+ "workspace body");
+ wfile_bin(ws, "skills/extra/skill.md",
+ "---\nid: extra\nname: Extra\n---\nextra body");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.agents_md && strcmp(d.agents_md, "workspace guidelines") == 0,
+ "workspace agents.md wins");
+ const skill *review = dotagents_find_skill(&d, "review");
+ CHECK(review && review->layer == LAYER_WORKSPACE, "review layer");
+ CHECK(review && strcmp(review->body, "workspace body") == 0,
+ "review body");
+ CHECK(d.skill_count == 2, "review + extra");
+ CHECK(d.memory_count == 1, "only global memory");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_json_layers_merge_by_key(void) {
+ char *ws = tmpdir("ws3");
+ char *global = tmpdir("g3");
+ wfile_bin(global, "models.json",
+ "{\"default\":\"ga\",\"presets\":{\"ga\":{\"model\":\"ga-"
+ "model\"},\"shared\":{\"model\":\"s\"}}}");
+ wfile_bin(global, "mcp.json",
+ "{\"mcpServers\":{\"fs\":{\"command\":\"npx\"},"
+ "\"git\":{\"url\":\"g\"}}}");
+ wfile_bin(ws, "models.json",
+ "{\"default\":\"wb\",\"presets\":{\"wb\":{\"provider\":\""
+ "ollama\"},\"shared\":{\"model\":\"ws\"}}}");
+ wfile_bin(ws, "mcp.json", "{\"mcpServers\":{\"git\":{\"url\":\"w\"}}}");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.has_models, "models present");
+ CHECK(d.models.default_name &&
+ strcmp(d.models.default_name, "wb") == 0,
+ "default=wb");
+ CHECK(d.models.preset_count == 3, "3 presets");
+ const model_preset *ga = models_file_get(&d.models, "ga");
+ CHECK(ga && ga->model && strcmp(ga->model, "ga-model") == 0, "ga.model");
+ const model_preset *sh = models_file_get(&d.models, "shared");
+ CHECK(sh && sh->model && strcmp(sh->model, "ws") == 0, "shared.model");
+ CHECK(d.mcp_count == 2, "2 mcp servers");
+ const char *git = dotagents_mcp_config(&d, "git");
+ CHECK(git && strstr(git, "\"url\":\"w\""), "git.url=w");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_malformed_files_warn_and_skip(void) {
+ char *ws = tmpdir("ws4");
+ wfile_bin(ws, "models.json", "{not json");
+ wfile_bin(ws, "mcp.json", "{\"nope\":true}");
+ wfile_bin(ws, "skills/broken/skill.md", "no frontmatter");
+ char cmd[300];
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s/skills/empty'", ws);
+ if (system(cmd) != 0)
+ exit(1);
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(!d.has_models, "no models");
+ CHECK(d.mcp_count == 0, "no mcp");
+ CHECK(d.skill_count == 1 && strcmp(d.skills[0].id, "broken") == 0,
+ "broken skill kept");
+ CHECK(d.skills[0].body &&
+ strstr(d.skills[0].body, "no frontmatter"),
+ "broken skill body");
+ CHECK(count_warns(&d, "models.json") > 0, "models.json warning");
+ CHECK(count_warns(&d, "mcp.json") > 0, "mcp.json warning");
+ CHECK(count_warns(&d, "skills/empty") > 0, "skills/empty warning");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_missing_layers_empty(void) {
+ char *ws = tmpdir("ws5");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 0, "no layers");
+ CHECK(d.skill_count == 0, "no skills");
+ CHECK(d.warnings.len == 0, "no warnings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_tasks_and_sub_agents(void) {
+ char *ws = tmpdir("ws6");
+ wfile_bin(ws, "tasks/daily/task.md",
+ "---\nkind: task\nid: daily\nname: Daily Review\nintervalMinutes"
+ ": 60\nenabled: true\nrunOnStartup: true\n---\nReview open "
+ "PRs.");
+ wfile_bin(ws, "agents/reviewer/agent.md",
+ "---\nid: reviewer\nname: Reviewer\ndescription: Reviews code\n"
+ "role: delegation-target\nenabled: true\n---\nYou are a "
+ "reviewer.");
+ wfile_bin(ws, "agents/reviewer/config.json",
+ "{\"modelConfig\":{\"mcpToolsOpenaiModel\":\"gpt-4o\"}}");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.task_count == 1, "one task");
+ CHECK(d.tasks[0].has_interval && d.tasks[0].interval_minutes == 60,
+ "interval");
+ CHECK(d.tasks[0].run_on_startup, "run_on_startup");
+ CHECK(d.tasks[0].enabled, "enabled");
+ CHECK(strcmp(d.tasks[0].body, "Review open PRs.") == 0, "task body");
+ CHECK(d.sub_agent_count == 1, "one sub agent");
+ CHECK(strcmp(d.sub_agents[0].role, "delegation-target") == 0, "role");
+ CHECK(strcmp(d.sub_agents[0].body, "You are a reviewer.") == 0,
+ "sub agent body");
+ CHECK(d.sub_agents[0].config != NULL, "config present");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_malformed_sub_agent_config(void) {
+ char *ws = tmpdir("ws9");
+ wfile_bin(ws, "agents/broken/agent.md",
+ "---\nid: broken\nname: Broken\ndescription: has a bad config\n"
+ "---\nYou are broken.");
+ wfile_bin(ws, "agents/broken/config.json", "{not json");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.sub_agent_count == 1, "one sub agent");
+ CHECK(d.sub_agents[0].config == NULL, "config dropped");
+ CHECK(count_warns(&d, "agents/broken/config.json") > 0,
+ "config warning");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_memories_rank(void) {
+ memory low, high;
+ memset(&low, 0, sizeof low);
+ low.importance = fm_strdup("low");
+ memset(&high, 0, sizeof high);
+ high.importance = fm_strdup("high");
+ CHECK(memory_importance_rank(&high) < memory_importance_rank(&low),
+ "high ranks before low");
+ free(low.importance);
+ free(high.importance);
+}
+
+static void test_project_sections(void) {
+ char *ws = tmpdir("ws7");
+ wfile_bin(ws, "agents.md", "# Guidelines\n- be nice");
+ wfile_bin(ws, "skills/cr/skill.md",
+ "---\nid: cr\nname: CR\ndescription: code review\n---\nreview "
+ "steps");
+ wfile_bin(ws, "memories/p.md",
+ "---\nid: p\ntitle: P\nimportance: high\ntags: x\n---\nbody");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ char *sections = dotagents_project_sections(&d, 50);
+ CHECK(strstr(sections, "- be nice"), "guidelines");
+ CHECK(strstr(sections, "- cr: code review"), "skill line");
+ CHECK(strstr(sections, "use_skill"), "use_skill mention");
+ CHECK(strstr(sections, "search_memories"), "search_memories mention");
+ char *pre = dotagents_compose_preamble(&d, "BASE", 50);
+ CHECK(strstr(pre, "BASE") && strstr(sections, "be nice") &&
+ memcmp(pre, "BASE", 4) == 0,
+ "preamble = base + sections");
+ free(sections);
+ free(pre);
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_skill_index_cap(void) {
+ char *ws = tmpdir("ws10");
+ const char *ids[5] = {"a", "b", "c", "d", "e"};
+ for (int i = 0; i < 5; i++) {
+ char rel[64];
+ char content[128];
+ snprintf(rel, sizeof rel, "skills/%s/skill.md", ids[i]);
+ snprintf(content, sizeof content,
+ "---\nid: %s\nname: %s\ndescription: skill %d\n---\nbody",
+ ids[i], ids[i], i);
+ wfile_bin(ws, rel, content);
+ }
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ char *sections = dotagents_project_sections(&d, 2);
+ CHECK(strstr(sections, "- a: skill 0"), "a listed");
+ CHECK(strstr(sections, "- b: skill 1"), "b listed");
+ CHECK(!strstr(sections, "- c: skill 2"), "c not listed");
+ CHECK(strstr(sections, "and 3 more"), "…and 3 more");
+ free(sections);
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_disabled_skills_excluded(void) {
+ char *ws = tmpdir("ws8");
+ wfile_bin(ws, "skills/off/skill.md",
+ "---\nid: off\ndescription: hidden\nenabled: false\n---\nx");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.skill_count == 1, "skill kept");
+ CHECK(!d.skills[0].enabled, "disabled");
+ char *sections = dotagents_project_sections(&d, 50);
+ CHECK(!strstr(sections, "off"), "not in sections");
+ free(sections);
+ dotagents_free(&d);
+ free(ws);
+}
+
+/* ------------------------------------------------------------------ */
+/* Spec-gap conformance */
+/* ------------------------------------------------------------------ */
+
+static void test_task_profile_id(void) {
+ char *ws = tmpdir("ws-pid");
+ wfile_bin(ws, "tasks/nightly/task.md",
+ "---\nkind: task\nid: nightly\nname: Nightly\nprofileId: "
+ "prof-9\nenabled: true\n---\nRun nightly.");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.task_count == 1, "one task");
+ CHECK(d.tasks[0].profile_id &&
+ strcmp(d.tasks[0].profile_id, "prof-9") == 0,
+ "profile_id parsed");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_task_profile_id_workspace_override(void) {
+ char *ws = tmpdir("ws-pid2");
+ char *global = tmpdir("g-pid");
+ wfile_bin(global, "tasks/nightly/task.md",
+ "---\nid: nightly\nprofileId: g-prof\n---\ng");
+ wfile_bin(ws, "tasks/nightly/task.md",
+ "---\nid: nightly\nprofileId: w-prof\n---\nw");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.task_count == 1, "one task");
+ CHECK(d.tasks[0].profile_id &&
+ strcmp(d.tasks[0].profile_id, "w-prof") == 0,
+ "workspace profileId wins");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_agent_connection_type(void) {
+ char *ws = tmpdir("ws-ct");
+ wfile_bin(ws, "agents/remote/agent.md",
+ "---\nid: remote\nname: Remote\nrole: delegation-target\n"
+ "connection-type: internal\nenabled: true\n---\nYou are "
+ "remote.");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.sub_agent_count == 1, "one agent");
+ CHECK(d.sub_agents[0].connection_type &&
+ strcmp(d.sub_agents[0].connection_type, "internal") == 0,
+ "connection-type parsed");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_settings_layers_merge(void) {
+ char *ws = tmpdir("ws-set");
+ char *global = tmpdir("g-set");
+ wfile_bin(global, "speakmcp-settings.json",
+ "{\"theme\":\"dark\",\"telemetry\":true,\"shared\":\"g\"}");
+ wfile_bin(ws, "speakmcp-settings.json",
+ "{\"shared\":\"w\",\"volume\":2}");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.settings_json != NULL, "settings present");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"shared\":\"w\""),
+ "shared=w");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"theme\":\"dark\""),
+ "theme kept");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"volume\":2"),
+ "volume kept");
+ CHECK(d.settings_json && strstr(d.settings_json, "\"telemetry\":true"),
+ "telemetry kept");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_settings_invalid_warns(void) {
+ char *ws = tmpdir("ws-setbad");
+ wfile_bin(ws, "speakmcp-settings.json", "{nope");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.settings_json == NULL, "no settings");
+ CHECK(count_warns(&d, "speakmcp-settings.json") > 0, "settings warning");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_layouts_ui_merge(void) {
+ char *ws = tmpdir("ws-lay");
+ char *global = tmpdir("g-lay");
+ wfile_bin(global, "layouts/ui.json", "{\"panes\":2,\"theme\":\"dark\"}");
+ wfile_bin(ws, "layouts/ui.json", "{\"theme\":\"light\"}");
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.layouts_json && strstr(d.layouts_json, "\"theme\":\"light\""),
+ "theme=w");
+ CHECK(d.layouts_json && strstr(d.layouts_json, "\"panes\":2"),
+ "panes kept");
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+static void test_settings_only_layer_discovered(void) {
+ char *ws = tmpdir("ws-setonly");
+ wfile_bin(ws, "speakmcp-settings.json", "{\"a\":1}");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 1, "layer found");
+ CHECK(d.settings_json != NULL, "settings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_layouts_only_layer_discovered(void) {
+ char *ws = tmpdir("ws-layonly");
+ wfile_bin(ws, "layouts/ui.json", "{\"a\":1}");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.found_count == 1, "layer found");
+ CHECK(d.layouts_json != NULL, "layouts");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_backups_ignored(void) {
+ char *ws = tmpdir("ws-bk");
+ wfile_bin(ws, "agents.md", "guidelines");
+ wfile_bin(ws, ".backups/skills/ghost/skill.md",
+ "---\nid: ghost\nname: Ghost\n---\nbody");
+ wfile_bin(ws, ".backups/memories/ghost.md",
+ "---\nid: ghost\ntitle: G\n---\nb");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.agents_md && strcmp(d.agents_md, "guidelines") == 0,
+ "agents_md");
+ CHECK(d.skill_count == 0, "no ghost skill");
+ CHECK(d.memory_count == 0, "no ghost memory");
+ dotagents_free(&d);
+ free(ws);
+}
+
+static void test_unknown_frontmatter_keys_accepted(void) {
+ char *ws = tmpdir("ws-unk");
+ wfile_bin(ws, "agents/x/agent.md",
+ "---\nkind: agent\nid: x\nname: X\nmetadata: stuff\n---\nb");
+ dotagents d;
+ dotagents_load_from(&d, ws, NULL);
+ CHECK(d.sub_agent_count == 1, "agent kept");
+ CHECK(d.warnings.len == 0, "no warnings");
+ dotagents_free(&d);
+ free(ws);
+}
+
+/* Both ~/.agents and ./.agents must remain reachable in the model context:
+ * a global-only skill and a workspace-only skill both survive the merge, both
+ * appear in the skill index the model sees, and an id conflict resolves to
+ * the workspace copy. */
+static void test_both_layers_reachable_in_context(void) {
+ char *ws = tmpdir("ws-ctx");
+ char *global = tmpdir("g-ctx");
+ wfile_bin(global, "skills/g-only/skill.md",
+ "---\nid: g-only\nname: G Only\ndescription: global only skill\n"
+ "---\ng global body");
+ wfile_bin(global, "skills/shared/skill.md",
+ "---\nid: shared\nname: Shared G\ndescription: shared global\n"
+ "---\ng shared body");
+ wfile_bin(ws, "skills/w-only/skill.md",
+ "---\nid: w-only\nname: W Only\ndescription: workspace only "
+ "skill\n---\nw workspace body");
+ wfile_bin(ws, "skills/shared/skill.md",
+ "---\nid: shared\nname: Shared W\ndescription: shared "
+ "workspace\n---\nw shared body");
+
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+
+ /* Neither layer's skills are lost. */
+ CHECK(d.skill_count == 3, "g-only + w-only + shared");
+
+ const skill *g = dotagents_find_skill(&d, "g-only");
+ CHECK(g && g->layer == LAYER_GLOBAL, "g-only reachable");
+ CHECK(g && g->body && strstr(g->body, "g global body"), "g-only body");
+
+ const skill *w = dotagents_find_skill(&d, "w-only");
+ CHECK(w && w->layer == LAYER_WORKSPACE, "w-only reachable");
+ CHECK(w && w->body && strstr(w->body, "w workspace body"), "w-only body");
+
+ /* An id conflict resolves to the workspace copy. */
+ const skill *sh = dotagents_find_skill(&d, "shared");
+ CHECK(sh && sh->layer == LAYER_WORKSPACE, "shared -> workspace");
+ CHECK(sh && sh->body && strstr(sh->body, "w shared body"),
+ "shared workspace body");
+
+ /* Both layers remain visible in the context skill index. */
+ char *sections = dotagents_project_sections(&d, 50);
+ CHECK(strstr(sections, "- g-only") != NULL, "g-only in context index");
+ CHECK(strstr(sections, "- w-only") != NULL, "w-only in context index");
+ CHECK(strstr(sections, "- shared") != NULL, "shared in context index");
+ free(sections);
+
+ /* ...and therefore in the composed preamble the model actually receives. */
+ char *pre = dotagents_compose_preamble(&d, "BASE", 50);
+ CHECK(strstr(pre, "g-only") != NULL && strstr(pre, "w-only") != NULL,
+ "both layers in composed preamble");
+ free(pre);
+
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+/* skill_index_cap must apply to the merged two-layer set, not silently drop
+ * global skills: with 3 global + 2 workspace skills and cap 2, the first two
+ * (by id) are listed and the rest are elided. */
+static void test_skill_index_cap_across_layers(void) {
+ char *ws = tmpdir("ws-cap");
+ char *global = tmpdir("g-cap");
+ wfile_bin(global, "skills/g1/skill.md",
+ "---\nid: g1\nname: G1\ndescription: g1\n---\nb");
+ wfile_bin(global, "skills/g2/skill.md",
+ "---\nid: g2\nname: G2\ndescription: g2\n---\nb");
+ wfile_bin(global, "skills/g3/skill.md",
+ "---\nid: g3\nname: G3\ndescription: g3\n---\nb");
+ wfile_bin(ws, "skills/w1/skill.md",
+ "---\nid: w1\nname: W1\ndescription: w1\n---\nb");
+ wfile_bin(ws, "skills/w2/skill.md",
+ "---\nid: w2\nname: W2\ndescription: w2\n---\nb");
+
+ dotagents d;
+ dotagents_load_from(&d, ws, global);
+ CHECK(d.skill_count == 5, "5 merged skills");
+
+ char *sections = dotagents_project_sections(&d, 2);
+ CHECK(strstr(sections, "- g1") != NULL, "g1 listed");
+ CHECK(strstr(sections, "- g2") != NULL, "g2 listed");
+ CHECK(!strstr(sections, "- g3"), "g3 capped");
+ CHECK(!strstr(sections, "- w1"), "w1 capped");
+ CHECK(strstr(sections, "and 3 more") != NULL, "...and 3 more");
+ free(sections);
+
+ dotagents_free(&d);
+ free(ws);
+ free(global);
+}
+
+int main(void) {
+ test_loads_global_only();
+ test_md_filenames_case_insensitive();
+ test_workspace_wins();
+ test_json_layers_merge_by_key();
+ test_malformed_files_warn_and_skip();
+ test_missing_layers_empty();
+ test_tasks_and_sub_agents();
+ test_malformed_sub_agent_config();
+ test_memories_rank();
+ test_project_sections();
+ test_skill_index_cap();
+ test_disabled_skills_excluded();
+ test_task_profile_id();
+ test_task_profile_id_workspace_override();
+ test_agent_connection_type();
+ test_settings_layers_merge();
+ test_settings_invalid_warns();
+ test_layouts_ui_merge();
+ test_settings_only_layer_discovered();
+ test_layouts_only_layer_discovered();
+ test_backups_ignored();
+ test_unknown_frontmatter_keys_accepted();
+ test_both_layers_reachable_in_context();
+ test_skill_index_cap_across_layers();
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_frontmatter.c b/tests/test_frontmatter.c
new file mode 100644
index 0000000..af4c2fc
--- /dev/null
+++ b/tests/test_frontmatter.c
@@ -0,0 +1,236 @@
+/* test_frontmatter.c — standalone test suite for frontmatter.h.
+ * Zero dependencies: compiles to a single TU with no linked libraries. */
+#include "frontmatter.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+/* ------------------------------------------------------------------ */
+/* Frontmatter */
+/* ------------------------------------------------------------------ */
+
+static void test_frontmatter_scalars_lists_quotes(void) {
+ doc d;
+ parse_frontmatter(
+ "---\nid: a-1\nname: \"A: One\"\nenabled: true\nn: 42\ntags: db, "
+ "arch\ntags2: [\"x\", \"y\"]\n---\nbody line",
+ &d);
+ CHECK(strcmp(d.body, "body line") == 0, "body");
+ const fm_value *v = doc_get(&d, "id");
+ CHECK(v && v->kind == FM_STR && strcmp(v->s, "a-1") == 0, "id");
+ v = doc_get(&d, "name");
+ CHECK(v && v->kind == FM_STR && strcmp(v->s, "A: One") == 0, "name");
+ v = doc_get(&d, "enabled");
+ CHECK(v && v->kind == FM_BOOL && v->b, "enabled");
+ v = doc_get(&d, "n");
+ CHECK(v && v->kind == FM_INT && v->i == 42, "n");
+ v = doc_get(&d, "tags");
+ CHECK(v, "tags present");
+ fm_strlist items;
+ fm_list_items(v, &items);
+ CHECK(items.len == 2 && strcmp(items.items[0], "db") == 0 &&
+ strcmp(items.items[1], "arch") == 0,
+ "tags csv");
+ fm_sl_free(&items);
+ v = doc_get(&d, "tags2");
+ CHECK(v && v->kind == FM_LIST && v->item_count == 2 &&
+ strcmp(v->items[0], "x") == 0 && strcmp(v->items[1], "y") == 0,
+ "tags2 array");
+ doc_free(&d);
+}
+
+static void test_frontmatter_missing_or_unterminated(void) {
+ doc d;
+ parse_frontmatter("just a body\n", &d);
+ CHECK(d.count == 0, "no meta");
+ CHECK(strcmp(d.body, "just a body") == 0, "body trimmed");
+ doc_free(&d);
+
+ parse_frontmatter("---\nid: x\nno closing fence\n", &d);
+ CHECK(d.count == 0, "unterminated: no meta");
+ CHECK(strcmp(d.body, "---\nid: x\nno closing fence") == 0,
+ "unterminated: whole text is body");
+ doc_free(&d);
+}
+
+static void test_frontmatter_ignores_blank_comment_colonless(void) {
+ doc d;
+ parse_frontmatter("---\n# comment\n\nnotakey\nk: v\n---\n", &d);
+ CHECK(d.count == 1, "one meta entry");
+ const fm_value *v = doc_get(&d, "k");
+ CHECK(v && v->kind == FM_STR && strcmp(v->s, "v") == 0, "k=v");
+ doc_free(&d);
+}
+
+static void test_fm_list_items_variants(void) {
+ doc d;
+ parse_frontmatter(
+ "---\ncsv: a, b, c\njson: [\"x\", \"y\"]\nsingle: only\nempty:\n"
+ "nums: [1, 2]\n---\nbody",
+ &d);
+ fm_strlist out;
+ fm_list_items(doc_get(&d, "csv"), &out);
+ CHECK(out.len == 3 && strcmp(out.items[0], "a") == 0 &&
+ strcmp(out.items[2], "c") == 0,
+ "csv");
+ fm_sl_free(&out);
+ fm_list_items(doc_get(&d, "json"), &out);
+ CHECK(out.len == 2 && strcmp(out.items[0], "x") == 0, "json");
+ fm_sl_free(&out);
+ fm_list_items(doc_get(&d, "single"), &out);
+ CHECK(out.len == 1 && strcmp(out.items[0], "only") == 0, "single");
+ fm_sl_free(&out);
+ fm_list_items(doc_get(&d, "empty"), &out);
+ CHECK(out.len == 0, "empty list");
+ fm_sl_free(&out);
+ fm_list_items(doc_get(&d, "nums"), &out);
+ CHECK(out.len == 2 && strcmp(out.items[0], "1") == 0, "non-string items");
+ fm_sl_free(&out);
+ fm_list_items(NULL, &out);
+ CHECK(out.len == 0, "null value");
+ fm_sl_free(&out);
+ doc_free(&d);
+}
+
+/* ------------------------------------------------------------------ */
+/* Embedded JSON */
+/* ------------------------------------------------------------------ */
+
+static void test_json_roundtrip(void) {
+ const char *src =
+ "{\"a\":1,\"b\":\"x\",\"c\":[true,null,1.5],\"d\":{\"e\":[1,2,"
+ "\"z\"]}}";
+ fm_json *v = fm_json_parse(src);
+ CHECK(v != NULL, "parse");
+ CHECK(v && v->type == FMJ_OBJECT, "object");
+ CHECK(v && v->count == 4, "4 keys");
+ const fm_json *a = fm_json_get(v, "a");
+ CHECK(a && a->type == FMJ_NUMBER && a->is_int && a->i == 1, "a=1");
+ const fm_json *b = fm_json_get(v, "b");
+ CHECK(b && b->type == FMJ_STRING && strcmp(b->str, "x") == 0, "b=x");
+ const fm_json *c = fm_json_get(v, "c");
+ CHECK(c && c->type == FMJ_ARRAY && c->count == 3, "c array");
+ CHECK(c && c->items[0]->type == FMJ_BOOL && c->items[0]->b, "c[0]=true");
+ CHECK(c && c->items[1]->type == FMJ_NULL, "c[1]=null");
+ CHECK(c && c->items[2]->type == FMJ_NUMBER && c->items[2]->num == 1.5,
+ "c[2]=1.5");
+ const fm_json *d = fm_json_get(v, "d");
+ CHECK(d && d->type == FMJ_OBJECT, "d object");
+ const fm_json *e = d ? fm_json_get(d, "e") : NULL;
+ CHECK(e && e->type == FMJ_ARRAY && e->count == 3, "d.e array");
+ CHECK(e && e->items[1]->is_int && e->items[1]->i == 2, "d.e[1]=2");
+ char *out = fm_json_serialize(v);
+ CHECK(out && strcmp(out, src) == 0, "roundtrip");
+ free(out);
+ fm_json_free(v);
+}
+
+static void test_json_escapes(void) {
+ fm_json *v = fm_json_parse("\"a\\\"b\\\\c\\nd\\u0041\"");
+ CHECK(v && v->type == FMJ_STRING, "parse");
+ CHECK(v && strcmp(v->str, "a\"b\\c\ndA") == 0, "unescaped value");
+ char *s = fm_json_serialize(v);
+ CHECK(s && strcmp(s, "\"a\\\"b\\\\c\\ndA\"") == 0, "escaped output");
+ free(s);
+ fm_json_free(v);
+
+ fm_json *u = fm_json_parse("\"\\u4e2d\"");
+ CHECK(u && u->type == FMJ_STRING, "unicode parse");
+ CHECK(u && strcmp(u->str, "\xE4\xB8\xAD") == 0, "utf-8 out");
+ fm_json_free(u);
+}
+
+static void test_json_invalid(void) {
+ CHECK(fm_json_parse("{") == NULL, "open brace");
+ CHECK(fm_json_parse("[1,") == NULL, "dangling comma");
+ CHECK(fm_json_parse("{bad:1}") == NULL, "bare key");
+ CHECK(fm_json_parse("{\"a\":1\"b\":2}") == NULL, "missing comma");
+ CHECK(fm_json_parse("{}x") == NULL, "trailing garbage");
+ CHECK(fm_json_parse("") == NULL, "empty");
+ CHECK(fm_json_parse("01") == NULL, "leading zero");
+ CHECK(fm_json_parse("1.") == NULL, "dangling dot");
+ CHECK(fm_json_parse("-") == NULL, "bare minus");
+ CHECK(fm_json_parse("tru") == NULL, "bare tru");
+ CHECK(fm_json_parse("\"unterminated") == NULL, "unterminated string");
+ CHECK(fm_json_parse("[1 2]") == NULL, "missing separator");
+ fm_json *f1 = fm_json_parse("1.5");
+ CHECK(f1 != NULL, "float ok");
+ fm_json_free(f1);
+ fm_json *f2 = fm_json_parse("1e3");
+ CHECK(f2 != NULL, "exponent ok");
+ fm_json_free(f2);
+ fm_json *f3 = fm_json_parse("null");
+ CHECK(f3 != NULL, "null ok");
+ fm_json_free(f3);
+ fm_json *f4 = fm_json_parse("[]");
+ CHECK(f4 != NULL, "empty array ok");
+ fm_json_free(f4);
+ fm_json *f5 = fm_json_parse("{}");
+ CHECK(f5 != NULL, "empty object ok");
+ fm_json_free(f5);
+}
+
+static void test_json_merge(void) {
+ fm_json *base = fm_json_parse("{\"a\":1,\"b\":{\"x\":1}}");
+ fm_json *over = fm_json_parse("{\"b\":{\"y\":2},\"c\":3}");
+ fm_json *m = fm_json_merge_owned(base, over);
+ char *s = fm_json_serialize(m);
+ CHECK(s && strcmp(s, "{\"b\":{\"y\":2},\"c\":3,\"a\":1}") == 0,
+ "merged content");
+ free(s);
+ fm_json_free(m);
+
+ fm_json *o2 = fm_json_parse("{\"z\":9}");
+ fm_json *m2 = fm_json_merge_owned(NULL, o2);
+ char *s2 = fm_json_serialize(m2);
+ CHECK(s2 && strcmp(s2, "{\"z\":9}") == 0, "over only");
+ free(s2);
+ fm_json_free(m2);
+
+ fm_json *b2 = fm_json_parse("{\"q\":1}");
+ fm_json *m3 = fm_json_merge_owned(b2, NULL);
+ char *s3 = fm_json_serialize(m3);
+ CHECK(s3 && strcmp(s3, "{\"q\":1}") == 0, "base only");
+ free(s3);
+ fm_json_free(m3);
+
+ fm_json *n1 = fm_json_parse("[1]");
+ fm_json *n2 = fm_json_parse("\"x\"");
+ fm_json *m4 = fm_json_merge_owned(n1, n2);
+ char *s4 = fm_json_serialize(m4);
+ CHECK(s4 && strcmp(s4, "{}") == 0, "non-object inputs dropped");
+ free(s4);
+ fm_json_free(m4);
+
+ fm_json *m5 = fm_json_merge_owned(NULL, NULL);
+ char *s5 = fm_json_serialize(m5);
+ CHECK(s5 && strcmp(s5, "{}") == 0, "both null");
+ free(s5);
+ fm_json_free(m5);
+}
+
+int main(void) {
+ test_frontmatter_scalars_lists_quotes();
+ test_frontmatter_missing_or_unterminated();
+ test_frontmatter_ignores_blank_comment_colonless();
+ test_fm_list_items_variants();
+ test_json_roundtrip();
+ test_json_escapes();
+ test_json_invalid();
+ test_json_merge();
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_goal.cpp b/tests/test_goal.cpp
new file mode 100644
index 0000000..8e06517
--- /dev/null
+++ b/tests/test_goal.cpp
@@ -0,0 +1,230 @@
+#include "goal.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+#define CHECK_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (!hay || !strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, \
+ "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \
+ __FILE__, __LINE__, (msg), hay ? hay : "(null)", \
+ (needle)); \
+ } \
+ } while (0)
+
+#define CHECK_NOT_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (hay && strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (found `%s` in `%s`)\n", \
+ __FILE__, __LINE__, (msg), (needle), hay); \
+ } \
+ } while (0)
+
+static void test_parse_defaults(void)
+{
+ goal_spec spec;
+ char *err = NULL;
+ CHECK(goal_parse("all tests pass", &spec, &err) == 1, "parse");
+ CHECK(strcmp(spec.criteria, "all tests pass") == 0, "criteria");
+ CHECK(spec.max_tries == GOAL_DEFAULT_MAX_TRIES, "default cap");
+ goal_spec_free(&spec);
+}
+
+static void test_parse_reads_cap(void)
+{
+ goal_spec spec;
+ char *err = NULL;
+ CHECK(goal_parse("all tests pass, stop after 3 tries", &spec, &err) == 1,
+ "parse");
+ CHECK(strcmp(spec.criteria, "all tests pass") == 0, "criteria");
+ CHECK(spec.max_tries == 3, "cap");
+ goal_spec_free(&spec);
+}
+
+static void test_parse_cap_without_tries_word(void)
+{
+ goal_spec spec;
+ char *err = NULL;
+ CHECK(goal_parse("make it green, stop after 10", &spec, &err) == 1, "parse");
+ CHECK(strcmp(spec.criteria, "make it green") == 0, "criteria");
+ CHECK(spec.max_tries == 10, "cap");
+ goal_spec_free(&spec);
+}
+
+static void test_parse_tries_word_case_insensitive(void)
+{
+ goal_spec spec;
+ char *err = NULL;
+ CHECK(goal_parse("ship it, stop after 4 TRIES", &spec, &err) == 1,
+ "uppercase TRIES accepted");
+ CHECK(spec.max_tries == 4, "cap");
+ goal_spec_free(&spec);
+}
+
+static void test_parse_rejections(void)
+{
+ goal_spec spec;
+ char *err = NULL;
+
+ err = NULL;
+ CHECK(goal_parse(" ", &spec, &err) == 0, "empty input rejected");
+ CHECK(err && strstr(err, "success criteria") != NULL, "empty err msg");
+ free(err);
+
+ err = NULL;
+ CHECK(goal_parse("stop after 5 tries", &spec, &err) == 0,
+ "missing criteria rejected");
+ CHECK(err && strstr(err, "before `stop after`") != NULL, "missing err msg");
+ free(err);
+
+ err = NULL;
+ CHECK(goal_parse("ship it, stop after", &spec, &err) == 0,
+ "missing cap rejected");
+ CHECK(err && strstr(err, "number of tries") != NULL, "missing cap err");
+ free(err);
+
+ err = NULL;
+ CHECK(goal_parse("ship it, stop after 0 tries", &spec, &err) == 0,
+ "zero cap rejected");
+ CHECK(err && strstr(err, "at least 1 try") != NULL, "zero cap err");
+ free(err);
+
+ err = NULL;
+ CHECK(goal_parse("ship it, stop after 5xyz", &spec, &err) == 0,
+ "junk after the number rejected");
+ CHECK(err && strstr(err, "number of tries") != NULL, "junk err msg");
+ free(err);
+}
+
+static void test_first_attempt_prompt(void)
+{
+ goal_spec spec;
+ spec.criteria = xstrdup("all tests pass");
+ spec.max_tries = 5;
+ char *prompt = goal_prompt(&spec, 1, NULL);
+ CHECK_CONT(prompt, "all tests pass", "states the goal");
+ CHECK_NOT_CONT(prompt, "NOT yet met", "no feedback on first attempt");
+ CHECK_CONT(prompt, "fully met", "asks to keep going");
+ free(prompt);
+ goal_spec_free(&spec);
+}
+
+static void test_later_attempt_prompt(void)
+{
+ goal_spec spec;
+ spec.criteria = xstrdup("all tests pass");
+ spec.max_tries = 3;
+ char *prompt = goal_prompt(&spec, 2, "the build still fails on x86");
+ CHECK_CONT(prompt, "attempt 2 of 3", "attempt framing");
+ CHECK_CONT(prompt, "NOT yet met", "verdict phrasing");
+ CHECK_CONT(prompt, "the build still fails on x86", "includes feedback");
+ free(prompt);
+
+ char *no_fb = goal_prompt(&spec, 4, NULL);
+ CHECK_CONT(no_fb, "attempt 4 of 3", "attempt number");
+ CHECK_CONT(no_fb, "no specific feedback was given", "fallback feedback");
+ free(no_fb);
+ goal_spec_free(&spec);
+}
+
+static void test_verdict_met(void)
+{
+ goal_verdict v;
+ goal_parse_verdict("MET\nAll tests pass and the suite is green.", &v);
+ CHECK(v.met, "met detected");
+ CHECK_CONT(v.reason, "green", "reason captured");
+ free(v.reason);
+}
+
+static void test_verdict_not_met(void)
+{
+ goal_verdict v;
+ goal_parse_verdict("NOT MET\nTwo tests still fail in the linux job.", &v);
+ CHECK(!v.met, "not met detected");
+ CHECK_CONT(v.reason, "still fail", "reason captured");
+ free(v.reason);
+}
+
+static void test_verdict_garbage(void)
+{
+ goal_verdict v;
+ goal_parse_verdict("I think we are close! Let me keep going.", &v);
+ CHECK(!v.met, "conservative on garbage");
+ CHECK_CONT(v.reason, "close", "first line is the reason");
+ free(v.reason);
+}
+
+static void test_verdict_unmet_is_not_met(void)
+{
+ /* "UNMET" contains the substring "MET"; it must not read as met. */
+ goal_verdict v;
+ goal_parse_verdict("UNMET\nStill failing.", &v);
+ CHECK(!v.met, "UNMET is not met");
+ free(v.reason);
+
+ goal_parse_verdict("NOT UNMET", &v);
+ CHECK(!v.met, "NOT UNMET is not met");
+ free(v.reason);
+
+ goal_parse_verdict("unmet", &v);
+ CHECK(!v.met, "lowercase unmet is not met");
+ free(v.reason);
+
+ goal_parse_verdict("MET", &v);
+ CHECK(v.met, "plain MET is met");
+ free(v.reason);
+}
+
+static void test_verdict_no_reason(void)
+{
+ goal_verdict v;
+ goal_parse_verdict("MET", &v);
+ CHECK(v.met, "bare MET is met");
+ CHECK_CONT(v.reason, "did not give a reason", "fallback reason");
+ free(v.reason);
+}
+
+static void test_preamble(void)
+{
+ const char *p = goal_evaluator_preamble();
+ CHECK_CONT(p, "MET", "mentions MET token");
+ CHECK_CONT(p, "NOT MET", "mentions NOT MET token");
+ CHECK_CONT(p, "strict", "strict reviewer");
+}
+
+int main(void)
+{
+ test_parse_defaults();
+ test_parse_reads_cap();
+ test_parse_cap_without_tries_word();
+ test_parse_tries_word_case_insensitive();
+ test_parse_rejections();
+ test_first_attempt_prompt();
+ test_later_attempt_prompt();
+ test_verdict_met();
+ test_verdict_not_met();
+ test_verdict_garbage();
+ test_verdict_unmet_is_not_met();
+ test_verdict_no_reason();
+ test_preamble();
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_input.cpp b/tests/test_input.cpp
new file mode 100644
index 0000000..99a719f
--- /dev/null
+++ b/tests/test_input.cpp
@@ -0,0 +1,1263 @@
+#include "input.h"
+#include "util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+#define CHECK_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (!hay || !strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, \
+ "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \
+ __FILE__, __LINE__, (msg), hay ? hay : "(null)", \
+ (needle)); \
+ } \
+ } while (0)
+
+#define CHECK_NOT_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (hay && strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (found `%s` in `%s`)\n", \
+ __FILE__, __LINE__, (msg), (needle), hay); \
+ } \
+ } while (0)
+
+#define CHECK_STR(got, want, msg) \
+ do { \
+ checks++; \
+ if (!got || strcmp(got, want) != 0) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \
+ __FILE__, __LINE__, (msg), got ? got : "(null)", (want));\
+ } \
+ } while (0)
+
+/* ------------------------------------------------------------------ */
+/* Helpers */
+/* ------------------------------------------------------------------ */
+
+static input_key kkind(int kind) {
+ input_key k;
+ k.kind = kind;
+ k.cp = 0;
+ return k;
+}
+
+static evlist feed(const unsigned char *bytes, size_t n) {
+ key_parser p;
+ evlist ev;
+ key_parser_init(&p);
+ evlist_init(&ev);
+ key_parser_feed(&p, bytes, n, &ev);
+ key_parser_flush(&p, &ev);
+ key_parser_free(&p);
+ return ev;
+}
+
+/* Apply a run of parsed keys; every action must be ACT_NONE. */
+static void type_str(editor *e, const char *s) {
+ evlist ev = feed((const unsigned char *)s, strlen(s));
+ for (size_t i = 0; i < ev.len; i++) {
+ input_action a;
+ editor_apply(e, ev.items[i], &a);
+ CHECK(a.kind == ACT_NONE, "type_str: unexpected action while typing");
+ input_action_free(&a);
+ }
+ evlist_free(&ev);
+}
+
+/* Apply a single named key and release its action. */
+static void apply_key(editor *e, int kind) {
+ input_action a;
+ editor_apply(e, kkind(kind), &a);
+ input_action_free(&a);
+}
+
+/* ================================================================== */
+/* Parser tests */
+/* ================================================================== */
+
+static void test_parser_plain_chars(void) {
+ evlist ev = feed((const unsigned char *)"abc", 3);
+ CHECK(ev.len == 3, "three chars");
+ if (ev.len == 3) {
+ CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'a', "a");
+ CHECK(ev.items[1].kind == KEY_CHAR && ev.items[1].cp == 'b', "b");
+ CHECK(ev.items[2].kind == KEY_CHAR && ev.items[2].cp == 'c', "c");
+ CHECK(ev.raws[0] && ev.lens[0] == 1 && ev.raws[0][0] == 'a', "raw a");
+ CHECK(ev.raws[2] && ev.lens[2] == 1 && ev.raws[2][0] == 'c', "raw c");
+ }
+ evlist_free(&ev);
+}
+
+static void test_parser_enter_variants(void) {
+ evlist ev = feed((const unsigned char *)"\r", 1);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_ENTER, "CR -> Enter");
+ evlist_free(&ev);
+
+ /* LF (Ctrl+J) is a dead key outside of paste: no event. */
+ ev = feed((const unsigned char *)"\n", 1);
+ CHECK(ev.len == 0, "LF -> no event (dead key)");
+ evlist_free(&ev);
+}
+
+/* Shift-modified Enter and Esc carry no special binding: they behave as the
+ * plain key. */
+static void test_parser_shift_enter_and_esc(void) {
+ evlist ev = feed((const unsigned char *)"\x1b[13;2u", 7);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_ENTER, "CSI 13;2 -> Enter");
+ evlist_free(&ev);
+
+ ev = feed((const unsigned char *)"\x1b[27;2u", 7);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_ESC, "CSI 27;2 -> Esc");
+ evlist_free(&ev);
+}
+
+static void test_parser_lone_escape_is_esc(void) {
+ /* a lone ESC is held until flush */
+ key_parser p;
+ evlist ev;
+ key_parser_init(&p);
+ evlist_init(&ev);
+ key_parser_feed(&p, (const unsigned char *)"\x1b", 1, &ev);
+ CHECK(ev.len == 0, "no event before flush");
+ CHECK(key_parser_flush(&p, &ev) == 1, "flush emits one");
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_ESC, "flush -> Esc");
+ evlist_free(&ev);
+ key_parser_free(&p);
+}
+
+static void test_parser_alt_letter_is_the_letter(void) {
+ evlist ev = feed((const unsigned char *)"\x1b" "a", 2);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'a',
+ "alt+a -> Char('a')");
+ if (ev.len == 1) {
+ CHECK(ev.raws[0] && ev.lens[0] == 2 && ev.raws[0][0] == '\x1b' &&
+ ev.raws[0][1] == 'a',
+ "raw = ESC a");
+ }
+ evlist_free(&ev);
+}
+
+static void test_parser_raw_bytes_preserved(void) {
+ evlist ev = feed((const unsigned char *)"\x1b[1;5A", 6);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_UP, "UP");
+ if (ev.len == 1) {
+ CHECK(ev.raws[0] && ev.lens[0] == 6, "raw len 6");
+ if (ev.raws[0] && ev.lens[0] == 6)
+ CHECK(memcmp(ev.raws[0], "\x1b[1;5A", 6) == 0, "raw bytes");
+ }
+ evlist_free(&ev);
+}
+
+static void test_parser_control_keys(void) {
+ struct {
+ unsigned char b;
+ int want;
+ const char *m;
+ } cases[] = {
+ {0x01, KEY_CTRL_A, "Ctrl-A"},
+ {0x03, KEY_CTRL_C, "Ctrl-C"},
+ {0x04, KEY_CTRL_D, "Ctrl-D"},
+ {0x05, KEY_CTRL_E, "Ctrl-E"},
+ {0x0B, KEY_CTRL_K, "Ctrl-K"},
+ {0x0C, KEY_CTRL_L, "Ctrl-L"},
+ {0x15, KEY_CTRL_U, "Ctrl-U"},
+ {0x17, KEY_CTRL_W, "Ctrl-W"},
+ {0x19, KEY_CTRL_Y, "Ctrl-Y"},
+ {0x1F, KEY_UNDO, "Ctrl-_ (undo)"},
+ };
+ for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
+ evlist ev = feed(&cases[i].b, 1);
+ CHECK(ev.len == 1, cases[i].m);
+ if (ev.len == 1) {
+ checks++;
+ if (ev.items[0].kind != cases[i].want) {
+ failures++;
+ fprintf(stderr, "FAIL %s:%d: %s (got %d want %d)\n", __FILE__,
+ __LINE__, cases[i].m, ev.items[0].kind, cases[i].want);
+ }
+ }
+ evlist_free(&ev);
+ }
+
+ unsigned char bs[2] = {0x7F, 0x08};
+ for (int i = 0; i < 2; i++) {
+ evlist ev = feed(&bs[i], 1);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_BACKSPACE, "backspace");
+ evlist_free(&ev);
+ }
+}
+
+static void test_parser_unmapped_control_bytes(void) {
+ int mapped[256] = {0};
+ mapped[0x09] = mapped[0x0A] = mapped[0x0D] = mapped[0x1B] = 1;
+ for (int b = 0; b < 0x20; b++) {
+ if (mapped[b])
+ continue;
+ unsigned char c = (unsigned char)b;
+ evlist ev = feed(&c, 1);
+ CHECK(ev.len == 1, "exactly one control event");
+ if (ev.len == 1)
+ CHECK(ev.items[0].kind != KEY_CHAR, "not a char");
+ evlist_free(&ev);
+ }
+}
+
+static void test_parser_unknown_sequence_consumed(void) {
+ // Unmapped CSI tilde: empty or a single Unknown (oracle allows either).
+ evlist ev = feed((const unsigned char *)"\x1b[999~", 6);
+ CHECK(ev.len == 0 || (ev.len == 1 && ev.items[0].kind == KEY_UNKNOWN),
+ "unknown CSI ~");
+ evlist_free(&ev);
+
+ // Empty bracketed paste: markers consumed, no events.
+ ev = feed((const unsigned char *)"\x1b[200~\x1b[201~", 12);
+ CHECK(ev.len == 0, "empty paste -> no events");
+ evlist_free(&ev);
+
+ // Mouse reports must not surface as editor keys.
+ ev = feed((const unsigned char *)"\x1b[M", 3);
+ CHECK(ev.len == 0, "mouse SGR -> no events");
+ evlist_free(&ev);
+
+ ev = feed((const unsigned char *)"\x1b[64;10;5M", 10);
+ CHECK(ev.len == 0, "mouse CSI -> no events");
+ evlist_free(&ev);
+
+ // Cursor position reports (DSR responses) are consumed.
+ ev = feed((const unsigned char *)"\x1b[12;40R", 8);
+ CHECK(ev.len == 0, "DSR report -> no events");
+ evlist_free(&ev);
+}
+
+static void test_parser_bracketed_paste(void) {
+ // Paste start marker: no event on its own.
+ evlist ev = feed((const unsigned char *)"\x1b[200~", 6);
+ CHECK(ev.len == 0, "paste start -> no event");
+ evlist_free(&ev);
+
+ // Pasted content is individual char events; a pasted CR is a Newline.
+ ev = feed((const unsigned char *)"\x1b[200~hello\rworld\x1b[201~", 23);
+ CHECK(ev.len == 11, "paste content = 11 events");
+ if (ev.len == 11) {
+ CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'h', "h");
+ CHECK(ev.items[4].kind == KEY_CHAR && ev.items[4].cp == 'o', "o");
+ CHECK(ev.items[5].kind == KEY_NEWLINE, "pasted CR -> Newline");
+ CHECK(ev.items[10].kind == KEY_CHAR && ev.items[10].cp == 'd', "d");
+ }
+ evlist_free(&ev);
+}
+
+static void test_parser_utf8(void) {
+ evlist ev = feed((const unsigned char *)"h\xc3\xa9llo", 6);
+ CHECK(ev.len == 5, "five codepoints");
+ if (ev.len == 5) {
+ CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'h', "h");
+ CHECK(ev.items[1].kind == KEY_CHAR && ev.items[1].cp == 0xE9, "e-acute");
+ CHECK(ev.items[4].kind == KEY_CHAR && ev.items[4].cp == 'o', "o");
+ }
+ evlist_free(&ev);
+
+ /* split across feed() calls */
+ key_parser p;
+ evlist ev2;
+ key_parser_init(&p);
+ evlist_init(&ev2);
+ key_parser_feed(&p, (const unsigned char *)"h\xc3", 2, &ev2);
+ CHECK(ev2.len == 1 && ev2.items[0].kind == KEY_CHAR &&
+ ev2.items[0].cp == 'h',
+ "h flushed, partial UTF-8 held");
+ key_parser_feed(&p, (const unsigned char *)"\xa9llo", 4, &ev2);
+ key_parser_flush(&p, &ev2);
+ CHECK(ev2.len == 5, "recombined to five codepoints");
+ evlist_free(&ev2);
+ key_parser_free(&p);
+
+ evlist ev3 = feed((const unsigned char *)"\xff", 1);
+ CHECK(ev3.len == 1 && ev3.items[0].kind == KEY_UNKNOWN, "invalid 0xFF");
+ evlist_free(&ev3);
+}
+
+static void test_parser_mixed_stream(void) {
+ unsigned char seq[] = "a\x1b[1;5A\xc3\xa9\x1b[13u";
+ evlist ev = feed(seq, sizeof(seq) - 1);
+ CHECK(ev.len == 4, "four events");
+ if (ev.len == 4) {
+ CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'a', "a");
+ CHECK(ev.items[1].kind == KEY_UP, "UP");
+ CHECK(ev.items[2].kind == KEY_CHAR && ev.items[2].cp == 0xE9, "e-acute");
+ CHECK(ev.items[3].kind == KEY_ENTER, "Enter (CSI 13)");
+ }
+ evlist_free(&ev);
+}
+
+static void test_parser_kitty_and_ss3(void) {
+ struct {
+ const char *seq;
+ int want;
+ const char *m;
+ } cases[] = {
+ {"\x1b[8593;1u", KEY_UP, "kitty up"},
+ {"\x1b[8594;1u", KEY_RIGHT, "kitty right"},
+ {"\x1b[9u", KEY_TAB, "kitty tab"},
+ {"\x1b[9;2u", KEY_BACKTAB, "kitty backtab"},
+ {"\x1b[12u", KEY_BACKSPACE, "kitty backspace"},
+ {"\x1b[127u", KEY_DELETE, "kitty delete"},
+ {"\x1b[13u", KEY_ENTER, "kitty enter"},
+ {"\x1b[27u", KEY_ESC, "kitty esc"},
+ {"\x1bOA", KEY_UP, "ss3 up"},
+ {"\x1bOD", KEY_LEFT, "ss3 left"},
+ {"\x1b[H", KEY_HOME, "csi home"},
+ {"\x1b[3~", KEY_DELETE, "csi delete"},
+ {"\x1b[4~", KEY_END, "csi end"},
+ {"\x1b[5~", KEY_PAGEUP, "csi pageup"},
+ {"\x1b[6~", KEY_PAGEDOWN, "csi pagedown"},
+ };
+ for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
+ const char *seq = cases[i].seq;
+ evlist ev = feed((const unsigned char *)seq, strlen(seq));
+ CHECK(ev.len == 1, cases[i].m);
+ if (ev.len == 1) {
+ checks++;
+ if (ev.items[0].kind != cases[i].want) {
+ failures++;
+ fprintf(stderr, "FAIL %s:%d: %s (got %d want %d)\n", __FILE__,
+ __LINE__, cases[i].m, ev.items[0].kind, cases[i].want);
+ }
+ }
+ evlist_free(&ev);
+ }
+}
+
+/* ================================================================== */
+/* Editor tests */
+/* ================================================================== */
+
+static void test_editor_typing_and_submit(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "hello");
+ char *t = editor_text(&e);
+ CHECK_STR(t, "hello", "text after typing");
+ free(t);
+
+ input_action a;
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_SUBMIT, "Enter submits");
+ if (a.kind == ACT_SUBMIT)
+ CHECK_STR(a.text, "hello", "submitted text");
+ input_action_free(&a);
+ editor_free(&e);
+}
+
+static void test_editor_shift_enter_inserts_newline(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "he");
+ apply_key(&e, KEY_NEWLINE);
+ type_str(&e, "llo");
+ char *t = editor_text(&e);
+ CHECK_STR(t, "he\nllo", "NEWLINE key inserts a newline");
+ free(t);
+ CHECK(editor_line_count(&e) == 2, "two lines");
+
+ input_action a;
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_SUBMIT, "Enter submits");
+ if (a.kind == ACT_SUBMIT)
+ CHECK_STR(a.text, "he\nllo", "submitted multi-line text");
+ input_action_free(&a);
+ editor_free(&e);
+}
+
+/* Backslash + Enter at the end of the buffer is a line continuation (a
+ * universal newline trigger for terminals where Shift+Enter is not
+ * distinguishable from Enter); any other Enter still submits. */
+static void test_editor_backslash_enter_continues(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "ls \\");
+ input_action a;
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_NEWLINE, "trailing backslash + Enter -> continuation");
+ input_action_free(&a);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "ls \n", "backslash became a newline");
+ free(t);
+ CHECK(e.caret == 4, "caret after the newline");
+
+ type_str(&e, "-la");
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_SUBMIT, "second line's Enter submits");
+ if (a.kind == ACT_SUBMIT)
+ CHECK_STR(a.text, "ls \n-la", "multi-line text submitted");
+ input_action_free(&a);
+ editor_free(&e);
+
+ /* No backslash before the caret -> Enter still submits. */
+ editor f;
+ editor_init(&f);
+ type_str(&f, "a\\b");
+ input_action b;
+ editor_apply(&f, kkind(KEY_ENTER), &b);
+ CHECK(b.kind == ACT_SUBMIT, "no backslash before caret submits");
+ if (b.kind == ACT_SUBMIT)
+ CHECK_STR(b.text, "a\\b", "text submitted unchanged");
+ input_action_free(&b);
+ editor_free(&f);
+
+ /* A backslash before the caret continues even with text after it. */
+ editor g;
+ editor_init(&g);
+ type_str(&g, "a\\b");
+ apply_key(&g, KEY_LEFT); /* caret sits right after the backslash */
+ input_action c;
+ editor_apply(&g, kkind(KEY_ENTER), &c);
+ CHECK(c.kind == ACT_NEWLINE, "backslash before caret continues");
+ input_action_free(&c);
+ char *gt = editor_text(&g);
+ CHECK_STR(gt, "a\nb", "newline inserted after the backslash");
+ free(gt);
+ editor_free(&g);
+}
+
+/* Regression: after building a multi-line buffer and moving the caret UP to
+ * a middle line, backslash+Enter on that line must split it (insert a
+ * newline), not submit. Previously the continuation only fired when the
+ * caret was at the very end of the whole buffer. */
+static void test_editor_middle_line_continuation(void) {
+ editor e;
+ editor_init(&e);
+ /* Build "alpha \nbeta": line 1 "alpha ", line 2 "beta". */
+ type_str(&e, "alpha ");
+ type_str(&e, "\\");
+ apply_key(&e, KEY_ENTER); /* "alpha \" -> "alpha \n" */
+ type_str(&e, "beta"); /* -> "alpha \nbeta", caret at end */
+ CHECK(editor_line_count(&e) == 2, "two lines before the split");
+ /* Move the caret UP to the middle (first) line. */
+ apply_key(&e, KEY_UP);
+ /* Walk to the end of that line (just before the newline). */
+ while (e.caret < strlen(e.text) && e.text[e.caret] != '\n')
+ apply_key(&e, KEY_RIGHT);
+ CHECK(e.caret == 6, "caret at end of the middle line");
+ /* Type a backslash on the middle line, then Enter -> split it. */
+ type_str(&e, "\\");
+ input_action a;
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_NEWLINE, "middle-line backslash+Enter continues");
+ input_action_free(&a);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "alpha \n\nbeta", "middle line was split");
+ free(t);
+ CHECK(editor_line_count(&e) == 3, "three lines after the split");
+
+ /* And the buffer still submits as a whole from its last line. */
+ apply_key(&e, KEY_DOWN);
+ while (e.caret < strlen(e.text) && e.text[e.caret] != '\n')
+ apply_key(&e, KEY_RIGHT);
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_SUBMIT, "last-line Enter submits");
+ if (a.kind == ACT_SUBMIT)
+ CHECK_STR(a.text, "alpha \n\nbeta", "multi-line submit intact");
+ input_action_free(&a);
+ editor_free(&e);
+}
+
+/* End-to-end regression: Ctrl+J is LF (0x0A). Outside of bracketed paste it
+ * is a dead key — it neither submits nor inserts a newline. Multi-line input
+ * is via backslash+Enter or bracketed paste. */
+static void test_ctrl_j_is_dead_key(void) {
+ key_parser p;
+ evlist ev;
+ key_parser_init(&p);
+ evlist_init(&ev);
+ key_parser_feed(&p, (const unsigned char *)"hi\n", 3, &ev);
+ key_parser_free(&p);
+ CHECK(ev.len == 2, "only the two chars, LF dropped");
+
+ editor e;
+ editor_init(&e);
+ input_action a = {ACT_NONE, NULL};
+ int submitted = 0;
+ for (size_t i = 0; i < ev.len; i++) {
+ editor_apply(&e, ev.items[i], &a);
+ if (a.kind == ACT_SUBMIT)
+ submitted = 1;
+ input_action_free(&a);
+ }
+ CHECK(!submitted, "Ctrl+J (LF) does not submit");
+ char *t = editor_text(&e);
+ CHECK_STR(t, "hi", "LF left no newline in the buffer");
+ free(t);
+ evlist_free(&ev);
+ editor_free(&e);
+}
+
+static void test_editor_backspace_and_delete(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "he");
+ apply_key(&e, KEY_NEWLINE);
+ type_str(&e, "llo");
+ apply_key(&e, KEY_LEFT);
+ apply_key(&e, KEY_LEFT);
+ apply_key(&e, KEY_LEFT);
+ apply_key(&e, KEY_BACKSPACE);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "hello", "backspace removed the newline");
+ CHECK(e.caret == 2, "caret at the join");
+ free(t);
+ editor_free(&e);
+
+ /* A forward-delete needs the caret at the end of the first line: Up gets
+ * there (Left is now clamped to a line), then Delete removes the newline. */
+ editor f;
+ editor_init(&f);
+ type_str(&f, "ab");
+ apply_key(&f, KEY_NEWLINE);
+ type_str(&f, "cd");
+ apply_key(&f, KEY_UP);
+ apply_key(&f, KEY_DELETE);
+ char *u = editor_text(&f);
+ CHECK_STR(u, "abcd", "delete removed the newline");
+ CHECK(f.caret == 2, "caret at the join");
+ free(u);
+ editor_free(&f);
+}
+
+static void test_editor_multibyte_backspace(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "h\xc3\xa9llo");
+ apply_key(&e, KEY_END);
+ apply_key(&e, KEY_BACKSPACE);
+ apply_key(&e, KEY_BACKSPACE);
+ apply_key(&e, KEY_BACKSPACE);
+ apply_key(&e, KEY_BACKSPACE);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "h", "removed whole codepoints");
+ free(t);
+ editor_free(&e);
+}
+
+static void test_editor_horizontal_movement(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "one");
+ apply_key(&e, KEY_NEWLINE);
+ type_str(&e, "two");
+ CHECK(e.caret == 7, "caret at the end");
+
+ /* Left stays on the current line: three presses reach its start, and a
+ * fourth is a no-op (it no longer crosses into the previous line). */
+ apply_key(&e, KEY_LEFT);
+ apply_key(&e, KEY_LEFT);
+ apply_key(&e, KEY_LEFT);
+ CHECK(e.caret == 4, "left moves the caret to the line start");
+ apply_key(&e, KEY_LEFT);
+ CHECK(e.caret == 4, "left is a no-op at the line start");
+ apply_key(&e, KEY_RIGHT);
+ CHECK(e.caret == 5, "right moves the caret");
+
+ /* HOME/END (and their Ctrl aliases) act on the current line, not the
+ * whole buffer. Move to the first line and exercise them there. */
+ apply_key(&e, KEY_UP);
+ CHECK(editor_caret_line(&e) == 0, "on the first line");
+ apply_key(&e, KEY_HOME);
+ CHECK(e.caret == 0, "home is the line start");
+ apply_key(&e, KEY_END);
+ CHECK(e.caret == 3, "end is the line end");
+ apply_key(&e, KEY_CTRL_A);
+ CHECK(e.caret == 0, "ctrl-a is the line start");
+ apply_key(&e, KEY_CTRL_E);
+ CHECK(e.caret == 3, "ctrl-e is the line end");
+ editor_free(&e);
+}
+
+/* Vertical movement walks the lines, keeps the column, clamps it to short
+ * lines on a UTF-8 boundary, and stops at the top/bottom edges. */
+static void test_editor_vertical_movement(void) {
+ editor e;
+ editor_init(&e);
+ editor_set(&e, "one\ntwo\nthree");
+ CHECK(editor_line_count(&e) == 3, "three lines");
+ CHECK(editor_caret_line(&e) == 2, "caret on the last line");
+ CHECK(e.caret == 13, "caret at the end");
+
+ apply_key(&e, KEY_UP);
+ CHECK(e.caret == 7, "up to the end of line 2");
+ CHECK(editor_caret_line(&e) == 1, "caret on line 2");
+ apply_key(&e, KEY_UP);
+ CHECK(e.caret == 3, "up to the end of line 1");
+ CHECK(editor_caret_line(&e) == 0, "caret on line 1");
+ apply_key(&e, KEY_UP);
+ CHECK(e.caret == 3, "up is a no-op on the first line");
+
+ apply_key(&e, KEY_DOWN);
+ CHECK(e.caret == 7, "down back to line 2");
+ apply_key(&e, KEY_DOWN);
+ CHECK(e.caret == 11, "down into line 3, column preserved");
+ CHECK(editor_caret_line(&e) == 2, "caret on line 3");
+ apply_key(&e, KEY_DOWN);
+ CHECK(e.caret == 11, "down is a no-op on the last line");
+ editor_free(&e);
+
+ /* Column clamping: a column longer than the target line clamps to its
+ * end, and the clamped column is what comes back. */
+ editor f;
+ editor_init(&f);
+ editor_set(&f, "abcdef\ngh");
+ CHECK(f.caret == 9, "set caret at the end");
+ apply_key(&f, KEY_UP);
+ CHECK(f.caret == 2, "up keeps the column");
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_RIGHT);
+ CHECK(f.caret == 6, "end of the long line");
+ apply_key(&f, KEY_DOWN);
+ CHECK(f.caret == 9, "down clamps the column to the short line");
+ apply_key(&f, KEY_UP);
+ CHECK(f.caret == 2, "up keeps the clamped column");
+ editor_free(&f);
+
+ /* A single-line buffer: UP/DOWN never move the caret. */
+ editor h;
+ editor_init(&h);
+ editor_set(&h, "hello");
+ apply_key(&h, KEY_HOME);
+ apply_key(&h, KEY_UP);
+ CHECK(h.caret == 0, "UP is a no-op on one line");
+ apply_key(&h, KEY_DOWN);
+ CHECK(h.caret == 0, "DOWN is a no-op on one line");
+ editor_free(&h);
+
+ /* UTF-8: a column landing mid-codepoint snaps to the boundary. */
+ editor u;
+ editor_init(&u);
+ editor_set(&u, "abc\n\xc3\xa9\xc3\xa9"); /* "abc", then two e-acute */
+ CHECK(u.caret == 8, "caret at the end");
+ apply_key(&u, KEY_UP);
+ CHECK(u.caret == 3, "up to the end of line 1");
+ apply_key(&u, KEY_DOWN);
+ CHECK(u.caret == 6, "down clamps between the two codepoints");
+ apply_key(&u, KEY_UP);
+ CHECK(u.caret == 2, "up keeps the clamped column");
+ editor_free(&u);
+}
+
+static void test_editor_insert_at_caret(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "helo");
+ apply_key(&e, KEY_HOME);
+ apply_key(&e, KEY_RIGHT);
+ type_str(&e, "i");
+ char *t = editor_text(&e);
+ CHECK_STR(t, "hielo", "inserted before the caret");
+ CHECK(e.caret == 2, "caret after the inserted char");
+ free(t);
+ editor_free(&e);
+
+ editor f;
+ editor_init(&f);
+ apply_key(&f, KEY_BACKSPACE);
+ CHECK(editor_is_empty(&f), "backspace at 0 is a no-op");
+ apply_key(&f, KEY_DELETE);
+ CHECK(editor_is_empty(&f), "delete at 0 is a no-op");
+ editor_free(&f);
+}
+
+static void test_editor_kill_and_word_kills(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "foo bar");
+ apply_key(&e, KEY_CTRL_K);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "foo bar", "Ctrl-K at end is a no-op");
+ free(t);
+ editor_free(&e);
+
+ editor f;
+ editor_init(&f);
+ type_str(&f, "foo bar");
+ apply_key(&f, KEY_HOME);
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_CTRL_K);
+ char *u = editor_text(&f);
+ CHECK_STR(u, "foo", "Ctrl-K cut the rest of the line");
+ free(u);
+ editor_free(&f);
+
+ editor g;
+ editor_init(&g);
+ type_str(&g, "foo bar");
+ apply_key(&g, KEY_END);
+ apply_key(&g, KEY_LEFT);
+ apply_key(&g, KEY_LEFT);
+ apply_key(&g, KEY_LEFT);
+ apply_key(&g, KEY_CTRL_U);
+ char *v = editor_text(&g);
+ CHECK_STR(v, "bar", "Ctrl-U cut the front of the line");
+ free(v);
+ editor_free(&g);
+
+ editor h;
+ editor_init(&h);
+ type_str(&h, "hello bar_baz");
+ apply_key(&h, KEY_END);
+ apply_key(&h, KEY_CTRL_W);
+ char *w = editor_text(&h);
+ CHECK_STR(w, "hello", "Ctrl-W removed one word");
+ free(w);
+ editor_free(&h);
+}
+
+static void test_editor_undo(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "hello");
+ char *t = editor_text(&e);
+ CHECK_STR(t, "hello", "typed");
+ free(t);
+
+ apply_key(&e, KEY_UNDO);
+ t = editor_text(&e);
+ CHECK_STR(t, "hell", "undo removed the last char");
+ free(t);
+
+ apply_key(&e, KEY_UNDO);
+ t = editor_text(&e);
+ CHECK_STR(t, "hel", "undo again");
+ free(t);
+
+ apply_key(&e, KEY_UNDO);
+ apply_key(&e, KEY_UNDO);
+ apply_key(&e, KEY_UNDO);
+ t = editor_text(&e);
+ CHECK_STR(t, "", "undo back to empty");
+ free(t);
+
+ apply_key(&e, KEY_UNDO);
+ t = editor_text(&e);
+ CHECK_STR(t, "", "undo past empty is a no-op");
+ free(t);
+ editor_free(&e);
+}
+
+static void test_editor_yank(void) {
+ /* Ctrl-W kill, then Ctrl-Y yank it back. */
+ editor e;
+ editor_init(&e);
+ type_str(&e, "foo bar");
+ apply_key(&e, KEY_END);
+ apply_key(&e, KEY_CTRL_W);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "foo", "Ctrl-W killed the last word");
+ free(t);
+ apply_key(&e, KEY_CTRL_Y);
+ t = editor_text(&e);
+ CHECK_STR(t, "foo bar", "Ctrl-Y yanked the killed word");
+ free(t);
+ editor_free(&e);
+
+ /* A backspaced char is also yankable. */
+ editor f;
+ editor_init(&f);
+ type_str(&f, "ab");
+ apply_key(&f, KEY_BACKSPACE);
+ char *u = editor_text(&f);
+ CHECK_STR(u, "a", "backspace removed b");
+ free(u);
+ apply_key(&f, KEY_CTRL_Y);
+ u = editor_text(&f);
+ CHECK_STR(u, "ab", "Ctrl-Y yanked the backspaced char");
+ free(u);
+ editor_free(&f);
+}
+
+static void test_editor_ctrl_c_and_ctrl_d(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "hello");
+ input_action a;
+ editor_apply(&e, kkind(KEY_CTRL_C), &a);
+ CHECK(a.kind == ACT_NONE, "Ctrl-C on non-empty is an edit op");
+ CHECK(editor_is_empty(&e), "Ctrl-C clears the buffer");
+ input_action_free(&a);
+
+ input_action q;
+ editor_apply(&e, kkind(KEY_CTRL_C), &q);
+ CHECK(q.kind == ACT_QUIT, "Ctrl-C on empty -> quit");
+ input_action_free(&q);
+ editor_free(&e);
+
+ editor f;
+ editor_init(&f);
+ input_action d;
+ editor_apply(&f, kkind(KEY_CTRL_D), &d);
+ CHECK(d.kind == ACT_QUIT, "Ctrl-D on empty -> quit");
+ input_action_free(&d);
+
+ type_str(&f, "hello");
+ input_action d2;
+ editor_apply(&f, kkind(KEY_CTRL_D), &d2);
+ CHECK(d2.kind == ACT_NONE, "Ctrl-D on non-empty is an edit op");
+ char *t = editor_text(&f);
+ CHECK_STR(t, "hello", "no-op at end of a single line");
+ free(t);
+ input_action_free(&d2);
+ editor_free(&f);
+}
+
+static void test_editor_backtab_tab_and_esc(void) {
+ editor e;
+ editor_init(&e);
+ input_action a;
+ editor_apply(&e, kkind(KEY_BACKTAB), &a);
+ CHECK(a.kind == ACT_TOGGLE_MODE, "backtab toggles mode");
+ input_action_free(&a);
+ editor_free(&e);
+
+ editor f;
+ editor_init(&f);
+ input_action t;
+ editor_apply(&f, kkind(KEY_TAB), &t);
+ CHECK(t.kind == ACT_NONE, "tab is an insert op");
+ char *ts = editor_text(&f);
+ CHECK_STR(ts, "\t", "tab inserted");
+ free(ts);
+ input_action_free(&t);
+ editor_free(&f);
+
+ editor g;
+ editor_init(&g);
+ input_action s;
+ editor_apply(&g, kkind(KEY_ESC), &s);
+ CHECK(s.kind == ACT_STOP, "esc -> stop");
+ input_action_free(&s);
+ editor_free(&g);
+}
+
+/* ================================================================== */
+/* Display tests */
+/* ================================================================== */
+
+static void test_display_len(void) {
+ CHECK(input_display_len("\x1b[1;36m›\x1b[0m ") == 2, "SGR ignored");
+ CHECK(input_display_len("plain") == 5, "plain ascii");
+ CHECK(input_display_len("") == 0, "empty");
+}
+
+static void test_display_len_wide(void) {
+ CHECK(input_display_len("\xe4\xb8\xad\xe6\x96\x87") == 4, "two CJK = 4");
+ CHECK(input_display_len("a\xe4\xb8\xad" "b") == 4, "a CJK b = 4");
+ CHECK(input_display_len("h\xc3\xa9llo") == 5, "accented = 5");
+}
+
+static void test_display_len_escape_families(void) {
+ CHECK(input_display_len("\x1b]0;llsh\x1b\\hi") == 2, "OSC st");
+ CHECK(input_display_len("\x1b]0;llsh\x07hi") == 2, "OSC BEL");
+ CHECK(input_display_len("\x1bOA") == 0, "SS3");
+ CHECK(input_display_len("\x1b(Bx") == 1, "charset");
+ CHECK(input_display_len("\x1b[c") == 0, "CSI cursor");
+ CHECK(input_display_len("a\x1b") == 1, "trailing lone ESC");
+}
+
+/* History tests */
+/* ================================================================== */
+
+static char *hist_dir(void) {
+ static char buf[512];
+ snprintf(buf, sizeof(buf), "/tmp/llshc_hist_%d", (int)getpid());
+ return buf;
+}
+
+static char *hist_path(const char *name) {
+ static char path[640];
+ snprintf(path, sizeof(path), "%s/%s", hist_dir(), name);
+ return path;
+}
+
+static void hist_cleanup(void) {
+ const char *names[] = {"a.json", "b.json"};
+ for (size_t i = 0; i < 2; i++) {
+ char p[640];
+ snprintf(p, sizeof(p), "%s/%s", hist_dir(), names[i]);
+ remove(p);
+ }
+ rmdir(hist_dir());
+}
+
+static void test_history_capture_submit_cycle(void) {
+ hist_cleanup();
+ char *path = hist_path("a.json");
+
+ input_history *h = input_history_open(path);
+ CHECK(h != NULL, "open history");
+ input_history_push(h, "echo hi");
+ input_history_push(h, "ls -la");
+
+ /* While composing, Up engages only from the first line (caret_line 0);
+ * a mid-buffer Up moves the caret, not history. */
+ CHECK(input_history_walk(h, -1, 1, "") == NULL, "Up mid-buffer -> no recall");
+
+ char *line = input_history_walk(h, -1, 0, "");
+ CHECK_STR(line, "ls -la", "newest first");
+ free(line);
+
+ /* Mid-walk: Up advances toward the oldest regardless of the caret line. */
+ line = input_history_walk(h, -1, 2, "");
+ CHECK_STR(line, "echo hi", "oldest second");
+ free(line);
+
+ /* Oldest already shown: Up hands back to the caret. */
+ CHECK(input_history_walk(h, -1, 0, "") == NULL, "Up at oldest -> NULL");
+
+ /* Down walks back toward the newest entry. */
+ line = input_history_walk(h, +1, 0, "");
+ CHECK_STR(line, "ls -la", "Down back to newest");
+ free(line);
+
+ /* Down past the newest restores the pre-recall line and ends the walk. */
+ line = input_history_walk(h, +1, 0, "");
+ CHECK_STR(line, "", "Down past newest restores pre-recall line");
+ free(line);
+ CHECK(h->active == false, "deactivated after restoring");
+
+ input_history_free(h);
+
+ input_history *h2 = input_history_open(path);
+ CHECK(h2 && h2->entries.len == 2, "reloaded 2 entries");
+ input_history_free(h2);
+ hist_cleanup();
+}
+
+static void test_history_restores_pre_recall_line(void) {
+ hist_cleanup();
+ char *path = hist_path("a.json");
+ input_history *h = input_history_open(path);
+ input_history_push(h, "first");
+ input_history_push(h, "second");
+
+ /* Down while composing never recalls — it moves the caret. */
+ CHECK(input_history_walk(h, +1, 0, "typed but not sent") == NULL,
+ "Down while composing -> no recall");
+
+ char *line = input_history_walk(h, -1, 0, "typed but not sent");
+ CHECK_STR(line, "second", "newest first");
+ free(line);
+
+ line = input_history_walk(h, +1, 0, "typed but not sent");
+ CHECK_STR(line, "typed but not sent", "restored the pre-recall line");
+ free(line);
+ CHECK(h->active == false, "deactivated after restoring");
+ CHECK(h->snapshot == NULL, "snapshot consumed");
+ input_history_free(h);
+ hist_cleanup();
+}
+
+static void test_history_skips_duplicates_and_empty(void) {
+ hist_cleanup();
+ char *path = hist_path("a.json");
+ input_history *h = input_history_open(path);
+ input_history_push(h, "same");
+ input_history_push(h, "same");
+ input_history_push(h, " ");
+ input_history_push(h, " spaced ");
+ input_history_push(h, "spaced");
+
+ CHECK(h->entries.len == 2, "two unique entries");
+ if (h->entries.len == 2) {
+ CHECK_STR(h->entries.items[0], "same", "entry 0");
+ CHECK_STR(h->entries.items[1], "spaced", "entry 1 trimmed");
+ }
+ input_history_free(h);
+ hist_cleanup();
+}
+
+static void test_history_persistence_and_limit(void) {
+ hist_cleanup();
+ char *path = hist_path("b.json");
+
+ input_history *h = input_history_open(path);
+ for (int i = 1; i <= LLSH_HISTORY_LIMIT + 1; i++) {
+ char buf[32];
+ snprintf(buf, sizeof(buf), "cmd%03d", i);
+ input_history_push(h, buf);
+ }
+ size_t live = h->entries.len - h->head;
+ CHECK(live == LLSH_HISTORY_LIMIT, "limit enforced");
+ if (live == LLSH_HISTORY_LIMIT) {
+ CHECK_STR(h->entries.items[h->head], "cmd002", "oldest evicted");
+ CHECK_STR(h->entries.items[h->entries.len - 1], "cmd101",
+ "newest preserved");
+ }
+ input_history_free(h);
+
+ input_history *h2 = input_history_open(path);
+ CHECK(h2 && h2->entries.len - h2->head == LLSH_HISTORY_LIMIT,
+ "reloaded at limit");
+ input_history_free(h2);
+ hist_cleanup();
+}
+
+/* ================================================================== */
+/* Shell shadow tests */
+/* ================================================================== */
+
+static void test_shell_shadow(void) {
+ hist_cleanup();
+ editor shadow;
+ editor_init(&shadow);
+ type_str(&shadow, "echo hi");
+ char *out = shell_shadow_apply(&shadow, kkind(KEY_ENTER));
+ CHECK_STR(out, "echo hi", "submitted line returned");
+ free(out);
+ CHECK(editor_is_empty(&shadow), "shadow cleared after submit");
+ editor_free(&shadow);
+
+ editor s2;
+ editor_init(&s2);
+ type_str(&s2, "ls");
+ char *o2 = shell_shadow_apply(&s2, kkind(KEY_LEFT));
+ CHECK(o2 == NULL, "intermediate key -> NULL");
+ CHECK(!editor_is_empty(&s2), "shadow NOT cleared on intermediate key");
+ editor_free(&s2);
+
+ editor s3;
+ editor_init(&s3);
+ type_str(&s3, " ");
+ char *o3 = shell_shadow_apply(&s3, kkind(KEY_ENTER));
+ CHECK(o3 == NULL, "blank submit -> NULL");
+ CHECK(!editor_is_empty(&s3), "shadow NOT cleared on blank");
+ editor_free(&s3);
+ hist_cleanup();
+}
+
+/* ================================================================== */
+/* Reader queue */
+/* ================================================================== */
+
+/* The reader's event queue must be FIFO: keys typed quickly must reach the
+ * editor in order (regression: a swap-pop scrambled the sequence, dropping
+ * and reordering characters). */
+static void test_reader_queue_fifo(void) {
+ reader_ctx *ctx = (reader_ctx* ) xmalloc(sizeof *ctx);
+ memset(ctx, 0, sizeof *ctx);
+ input_key_reader *r = input_key_reader_new(ctx);
+
+ const unsigned int cps[] = {'a', 'b', 'c', 'd', 'e', 'f'};
+ for (size_t i = 0; i < sizeof cps / sizeof *cps; i++) {
+ input_key k = {KEY_CHAR, cps[i]};
+ input_key_reader_send(r, &k);
+ }
+ for (size_t i = 0; i < sizeof cps / sizeof *cps; i++) {
+ input_key out;
+ const unsigned char *raw;
+ size_t raw_len;
+ CHECK(input_key_reader_recv(r, &out, &raw, &raw_len),
+ "recv returns queued event");
+ CHECK(out.kind == KEY_CHAR, "queued key kind preserved");
+ CHECK(out.cp == cps[i], "queue is FIFO (order preserved)");
+ input_raw_free(raw);
+ }
+ input_key out;
+ CHECK(!input_key_reader_recv(r, &out, NULL, NULL), "queue drained");
+ input_key_reader_free(r);
+}
+
+/* The raw buffer handed out by recv is owned by the caller: it must stay
+ * valid no matter what the queue does afterwards (regression: recv used to
+ * pop-and-free the very buffer it returned, so callers read freed memory and
+ * shell-mode keystrokes arrived scrambled). */
+static void test_recv_raw_owned(void) {
+ reader_ctx *ctx = (reader_ctx* ) xmalloc(sizeof *ctx);
+ memset(ctx, 0, sizeof *ctx);
+ input_key_reader *r = input_key_reader_new(ctx);
+
+ input_key k = {KEY_CHAR, 'x'};
+ input_key_reader_send_raw(r, &k, "payload");
+
+ input_key out;
+ const unsigned char *raw1 = NULL;
+ size_t raw_len = 0;
+ CHECK(input_key_reader_recv(r, &out, &raw1, &raw_len), "recv raw event");
+ CHECK(raw_len == 7, "raw length preserved");
+
+ /* Fill the ring past one full lap so the queue reuses the first slot;
+ * with a borrowed (freed-on-pop) pointer, `raw1` would now be garbage. */
+ for (int i = 0; i < 16; i++)
+ input_key_reader_send_raw(r, &k, "scratch");
+ for (int i = 0; i < 16; i++) {
+ input_key o2;
+ const unsigned char *r2 = NULL;
+ if (!input_key_reader_recv(r, &o2, &r2, &raw_len))
+ break;
+ input_raw_free(r2);
+ }
+
+ CHECK(raw1 && raw1[0] == 'p' && memcmp(raw1, "payload", 7) == 0,
+ "recv'd raw stays valid after the queue laps it");
+ input_raw_free(raw1);
+ input_key_reader_free(r);
+}
+
+/* ================================================================== */
+
+/* The renderer and these tests share input_wrap_total / input_wrap_caret to
+ * agree on how a buffer breaks into physical rows and where the caret lands on
+ * a terminal of a given width, using the delayed-wrap model real terminals use.
+ * Line 0 carries the 2-column prompt. */
+static void test_wrap_geometry(void) {
+ size_t r, c;
+
+ /* A short first line: the prompt takes the first two columns, nothing
+ * wraps. */
+ CHECK(input_wrap_total("hi", 10) == 1, "short line is one row");
+ input_wrap_caret("hi", 0, 10, &r, &c);
+ CHECK(r == 0 && c == 2, "caret at text start sits after the prompt");
+ input_wrap_caret("hi", 2, 10, &r, &c);
+ CHECK(r == 0 && c == 4, "caret at the text end");
+
+ /* A first line long enough to wrap: prompt + text flow across rows. The
+ * cursor sits on the last typed column, not a spurious next row. */
+ CHECK(input_wrap_total("abcdef", 5) == 2, "wrapped line is two rows");
+ input_wrap_caret("abcdef", 0, 5, &r, &c);
+ CHECK(r == 0 && c == 2, "caret before the first char");
+ input_wrap_caret("abcdef", 3, 5, &r, &c);
+ CHECK(r == 0 && c == 4, "caret after 'abc' sits on the last column");
+ input_wrap_caret("abcdef", 6, 5, &r, &c);
+ CHECK(r == 1 && c == 3, "caret at the wrapped end");
+
+ /* A multi-line buffer: only line 0 carries the prompt. */
+ CHECK(input_wrap_total("ab\ncdef", 5) == 2, "two short lines are two rows");
+ input_wrap_caret("ab\ncdef", 7, 5, &r, &c);
+ CHECK(r == 1 && c == 4, "caret at the buffer end");
+ input_wrap_caret("ab\ncdef", 5, 5, &r, &c);
+ CHECK(r == 1 && c == 2, "caret mid second line");
+
+ /* Regression: a first line that ends exactly at the margin, followed by a
+ * newline, must NOT gain a spurious empty row. '» ab' is exactly 4
+ * columns, so the 'cdefgh' line starts immediately on row 1. */
+ CHECK(input_wrap_total("ab\ncdefgh", 4) == 3,
+ "full first line + newline is three rows, not four");
+ input_wrap_caret("ab\ncdefgh", 9, 4, &r, &c);
+ CHECK(r == 2 && c == 2, "caret at the end of the third row");
+ input_wrap_caret("ab\ncdefgh", 2, 4, &r, &c);
+ CHECK(r == 0 && c == 3, "caret at the end of the full first row");
+ input_wrap_caret("ab\ncdefgh", 3, 4, &r, &c);
+ CHECK(r == 1 && c == 0, "caret at the start of the second line");
+
+ /* A trailing full line keeps the cursor on its last column (no empty row). */
+ CHECK(input_wrap_total("abcdef", 4) == 2, "two full rows is two rows");
+ input_wrap_caret("abcdef", 6, 4, &r, &c);
+ CHECK(r == 1 && c == 3, "caret at the end of a full row sits on it");
+
+ /* The renderer needs the pending flag to tell a cursor sitting on the last
+ * column (delayed wrap) from one merely after the second-to-last column. */
+ CHECK(input_wrap_pending("hi", 2, 10) == false,
+ "short end is not pending");
+ CHECK(input_wrap_pending("abcdef", 3, 5) == true,
+ "caret after the last-column char is pending");
+ CHECK(input_wrap_pending("abcdef", 4, 5) == false,
+ "caret on the wrapped row is not pending");
+ CHECK(input_wrap_pending("abcdefg", 7, 10) == false,
+ "second-to-last column end is not pending");
+ CHECK(input_wrap_pending("abcdefgh", 8, 10) == true,
+ "last-column end is pending");
+ CHECK(input_wrap_pending("a\xE4\xB8\xAD", 4, 10) == false,
+ "three-byte char advances one column");
+ CHECK(input_wrap_pending("a\xF0\x9F\x98\x80", 5, 10) == false,
+ "four-byte char advances one column");
+ CHECK(input_wrap_pending("a", 1, 1) == true,
+ "one-column terminal stays pending");
+ CHECK(input_wrap_pending("a\n", 2, 1) == false,
+ "newline clears pending wrap");
+ CHECK(input_wrap_pending("ab\ncdef\n", 8, 5) == false,
+ "a trailing newline is not pending");
+
+ /* A blank buffer still renders the prompt as one row. */
+ CHECK(input_wrap_total("", 10) == 1, "blank buffer is one row");
+ input_wrap_caret("", 0, 10, &r, &c);
+ CHECK(r == 0 && c == 2, "caret sits after the prompt");
+}
+
+int main(void) {
+ /* parser */
+ test_parser_plain_chars();
+ test_parser_enter_variants();
+ test_parser_shift_enter_and_esc();
+ test_parser_lone_escape_is_esc();
+ test_parser_alt_letter_is_the_letter();
+ test_parser_raw_bytes_preserved();
+ test_parser_control_keys();
+ test_parser_unmapped_control_bytes();
+ test_parser_unknown_sequence_consumed();
+ test_parser_bracketed_paste();
+ test_parser_utf8();
+ test_parser_mixed_stream();
+ test_parser_kitty_and_ss3();
+
+ /* editor */
+ test_editor_typing_and_submit();
+ test_editor_shift_enter_inserts_newline();
+ test_editor_backslash_enter_continues();
+ test_editor_middle_line_continuation();
+ test_ctrl_j_is_dead_key();
+ test_editor_backspace_and_delete();
+ test_editor_multibyte_backspace();
+ test_editor_horizontal_movement();
+ test_editor_vertical_movement();
+ test_editor_insert_at_caret();
+ test_editor_kill_and_word_kills();
+ test_editor_undo();
+ test_editor_yank();
+ test_editor_ctrl_c_and_ctrl_d();
+ test_editor_backtab_tab_and_esc();
+
+ /* display */
+ test_display_len();
+ test_display_len_wide();
+ test_display_len_escape_families();
+
+ /* wrap geometry */
+ test_wrap_geometry();
+
+ /* history */
+ test_history_capture_submit_cycle();
+ test_history_restores_pre_recall_line();
+ test_history_skips_duplicates_and_empty();
+ test_history_persistence_and_limit();
+
+ /* shell shadow */
+ test_shell_shadow();
+
+ /* reader queue */
+ test_reader_queue_fifo();
+ test_recv_raw_owned();
+
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_limits.cpp b/tests/test_limits.cpp
new file mode 100644
index 0000000..f425d64
--- /dev/null
+++ b/tests/test_limits.cpp
@@ -0,0 +1,379 @@
+#include "limits.h"
+#include "verbose.h"
+
+#include <cjson/cJSON.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+#define CHECK_EQ_STR(a, b, msg) \
+ do { \
+ checks++; \
+ const char *_a = (a), *_b = (b); \
+ if (!_a || (_b && strcmp(_a, _b) != 0)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \
+ __FILE__, __LINE__, (msg), _a ? _a : "(null)", \
+ _b ? _b : "(null)"); \
+ } \
+ } while (0)
+
+static tool_limits mklimits(const char **allow, size_t nallow,
+ const char **deny, size_t ndeny) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ for (size_t i = 0; i < nallow; i++)
+ sl_push(&l.shell_allowlist, xstrdup(allow[i]));
+ for (size_t i = 0; i < ndeny; i++)
+ sl_push(&l.shell_denylist, xstrdup(deny[i]));
+ return l;
+}
+
+static void test_defaults(void) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ CHECK(l.shell_timeout_secs == 120, "default shell_timeout_secs");
+ CHECK(l.max_output_chars == 16000, "default max_output_chars");
+ CHECK(l.max_file_bytes == 2000000, "default max_file_bytes");
+ CHECK(l.shell_allowlist.len == 0, "default allowlist empty");
+ CHECK(l.shell_denylist.len == 0, "default denylist empty");
+ CHECK(!l.confirm_destructive, "default confirm_destructive off");
+ CHECK(!l.verbose, "default verbose off");
+ tool_limits_free(&l);
+}
+
+static void test_set_get_roundtrip(void) {
+ const char *allow[] = {"git", "ls"};
+ const char *deny[] = {"git push"};
+ tool_limits l = mklimits(allow, 2, deny, 1);
+ l.confirm_destructive = true;
+ l.verbose = true;
+ tool_limits_set(&l);
+ tool_limits g;
+ tool_limits_get(&g);
+ CHECK(g.shell_timeout_secs == 120, "get timeout");
+ CHECK(g.confirm_destructive, "get confirm_destructive");
+ CHECK(g.verbose, "get verbose");
+ CHECK(g.shell_allowlist.len == 2, "get allowlist len");
+ CHECK(g.shell_allowlist.len == 2 &&
+ strcmp(g.shell_allowlist.items[0], "git") == 0,
+ "get allowlist[0]");
+ CHECK(g.shell_allowlist.len == 2 &&
+ strcmp(g.shell_allowlist.items[1], "ls") == 0,
+ "get allowlist[1]");
+ CHECK(g.shell_denylist.len == 1 &&
+ strcmp(g.shell_denylist.items[0], "git push") == 0,
+ "get denylist[0]");
+ tool_limits_free(&l);
+ tool_limits_free(&g);
+ /* restore defaults for later tests */
+ tool_limits d;
+ tool_limits_defaults(&d);
+ tool_limits_set(&d);
+ tool_limits_free(&d);
+}
+
+static void test_allowlist_allows_matching_and_refuses_others(void) {
+ const char *allow[] = {"git", "ls"};
+ tool_limits l = mklimits(allow, 2, NULL, 0);
+ char *reason = NULL;
+ CHECK(shell_policy("git status", &l, NULL) == SHELL_ALLOWED,
+ "git status allowed");
+ CHECK(shell_policy("ls -la", &l, NULL) == SHELL_ALLOWED, "ls -la allowed");
+ CHECK(shell_policy("rm -rf x", &l, &reason) == SHELL_DENIED,
+ "rm -rf x refused");
+ CHECK(reason && strstr(reason, "allowlist") != NULL,
+ "refusal mentions allowlist");
+ free(reason);
+ tool_limits_free(&l);
+}
+
+static void test_allowlist_entry_does_not_match_longer_word(void) {
+ const char *allow[] = {"git"};
+ tool_limits l = mklimits(allow, 1, NULL, 0);
+ char *reason = NULL;
+ CHECK(shell_policy("igit x", &l, &reason) == SHELL_DENIED,
+ "igit x refused (no word-boundary match)");
+ free(reason);
+ CHECK(shell_policy("git push", &l, NULL) == SHELL_ALLOWED,
+ "git push allowed by bare-word entry");
+ tool_limits_free(&l);
+}
+
+static void test_denylist_wins_over_allowlist(void) {
+ const char *allow[] = {"git"};
+ const char *deny[] = {"git push"};
+ tool_limits l = mklimits(allow, 1, deny, 1);
+ CHECK(shell_policy("git status", &l, NULL) == SHELL_ALLOWED,
+ "git status allowed despite denylist presence");
+ char *reason = NULL;
+ CHECK(shell_policy("git push origin", &l, &reason) == SHELL_DENIED,
+ "git push origin refused by denylist");
+ CHECK_EQ_STR(
+ reason,
+ "command matches the denylist entry `git push`",
+ "exact denylist reason");
+ free(reason);
+ tool_limits_free(&l);
+}
+
+static void test_allowlist_reason_string(void) {
+ const char *allow[] = {"git", "ls"};
+ tool_limits l = mklimits(allow, 2, NULL, 0);
+ char *reason = NULL;
+ CHECK(shell_policy("rm -rf x", &l, &reason) == SHELL_DENIED, "denied");
+ CHECK_EQ_STR(reason, "command not in the allowlist (git, ls)",
+ "exact allowlist reason");
+ free(reason);
+ tool_limits_free(&l);
+}
+
+static void test_no_allowlist_means_no_allowlist(void) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ CHECK(shell_policy("anything at all", &l, NULL) == SHELL_ALLOWED,
+ "no allowlist: everything allowed");
+ tool_limits_free(&l);
+}
+
+static void test_gate_reason_wrapping(void) {
+ const char *deny[] = {"git push"};
+ tool_limits l = mklimits(NULL, 0, deny, 1);
+ char *err = NULL;
+ CHECK(shell_gate("git push origin", &l, &err) == -1, "gate denies");
+ CHECK_EQ_STR(err, "run_shell blocked: command matches the denylist "
+ "entry `git push`",
+ "gate wraps policy reason");
+ free(err);
+ tool_limits_free(&l);
+}
+
+static void test_destructive_detection(void) {
+ CHECK(is_destructive("rm -rf /tmp/x"), "rm is destructive");
+ CHECK(is_destructive("git reset --hard"), "git reset is destructive");
+ CHECK(is_destructive("dd if=/dev/zero of=/dev/sda bs=1M"),
+ "dd with of= is destructive");
+ CHECK(!is_destructive("ls -la"), "ls is not destructive");
+ CHECK(!is_destructive("git status"), "git status is not destructive");
+ CHECK(!is_destructive("echo hi"), "echo is not destructive");
+ CHECK(is_destructive("chmod 777 /etc/passwd"), "chmod is destructive");
+ CHECK(!is_destructive("dd if=/dev/zero bs=1M"),
+ "dd without of= is not destructive");
+ CHECK(is_destructive("git checkout -- ."), "git checkout is destructive");
+ CHECK(!is_destructive("git commit -m x"), "git commit is not destructive");
+ CHECK(is_destructive(" rm x"), "leading whitespace still destructive");
+ CHECK(is_destructive("echo safe; rm x"), "compound command requires confirmation");
+ CHECK(is_destructive("echo safe | rm x"), "pipeline requires confirmation");
+ CHECK(is_destructive("echo $(rm x)"), "expansion requires confirmation");
+ CHECK(!is_destructive(""), "empty command not destructive");
+}
+
+static bool hook_yes(const char *cmd, void *ud) { (void)cmd; (void)ud; return true; }
+static int hook_calls = 0;
+static char hook_last[256];
+static bool hook_record(const char *cmd, void *ud) {
+ (void)ud;
+ hook_calls++;
+ snprintf(hook_last, sizeof hook_last, "%s", cmd);
+ return false;
+}
+
+static void test_gate_refuses_when_hook_declines(void) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ l.confirm_destructive = true;
+ set_confirm_hook(hook_record, NULL);
+ hook_calls = 0;
+ char *err = NULL;
+ CHECK(shell_gate("rm -rf /tmp/x", &l, &err) == -1, "declined => -1");
+ CHECK(err && strstr(err, "declined") != NULL, "reason says declined");
+ CHECK_EQ_STR(err, "user declined to run destructive command: "
+ "rm -rf /tmp/x",
+ "exact decline reason");
+ free(err);
+ CHECK(hook_calls == 1, "hook invoked exactly once");
+ CHECK_EQ_STR(hook_last, "rm -rf /tmp/x", "hook received the command");
+ set_confirm_hook(NULL, NULL);
+ tool_limits_free(&l);
+}
+
+static void test_gate_allows_when_hook_approves(void) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ l.confirm_destructive = true;
+ set_confirm_hook(hook_yes, NULL);
+ char *err = NULL;
+ CHECK(shell_gate("rm -rf /tmp/x", &l, &err) == 0, "approved => 0");
+ CHECK(err == NULL, "no error on approval");
+ free(err);
+ set_confirm_hook(NULL, NULL);
+ tool_limits_free(&l);
+}
+
+static void test_gate_no_hook_denies(void) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ l.confirm_destructive = true;
+ set_confirm_hook(NULL, NULL);
+ char *err = NULL;
+ CHECK(shell_gate("rm -rf /tmp/x", &l, &err) == -1,
+ "no hook: destructive command denied");
+ CHECK(err && strstr(err, "declined"), "no-hook denial has reason");
+ free(err);
+ tool_limits_free(&l);
+}
+
+static void test_gate_non_destructive_skips_hook(void) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ l.confirm_destructive = true;
+ set_confirm_hook(hook_record, NULL);
+ hook_calls = 0;
+ char *err = NULL;
+ CHECK(shell_gate("ls -la", &l, &err) == 0, "non-destructive passes");
+ CHECK(hook_calls == 0, "hook not consulted for non-destructive");
+ free(err);
+ set_confirm_hook(NULL, NULL);
+ tool_limits_free(&l);
+}
+
+static void test_policy_null_reason(void) {
+ const char *deny[] = {"git push"};
+ tool_limits l = mklimits(NULL, 0, deny, 1);
+ CHECK(shell_policy("git push", &l, NULL) == SHELL_DENIED, "denied");
+ CHECK(shell_policy("ls", &l, NULL) == SHELL_ALLOWED, "allowed");
+ tool_limits_free(&l);
+}
+
+static void test_policy_checks_wrapped_commands(void) {
+ const char *deny[] = {"rm"};
+ tool_limits l = mklimits(NULL, 0, deny, 1);
+ CHECK(shell_policy("sudo -u root rm -rf /tmp/x", &l, NULL) == SHELL_DENIED,
+ "denylist catches sudo-wrapped rm");
+ CHECK(is_destructive("env PATH=/bin git clean -fd"),
+ "destructive classifier catches env-wrapped git clean");
+ CHECK(is_destructive("sudo git push origin main"),
+ "destructive classifier catches sudo-wrapped git push");
+ tool_limits_free(&l);
+}
+
+/* ---- verbose ---- */
+
+static char **vlines = NULL;
+static size_t vline_n = 0, vline_cap = 0;
+
+static void vrecord(const char *line, void *ud) {
+ (void)ud;
+ if (vline_n + 1 > vline_cap) {
+ vline_cap = vline_cap ? vline_cap * 2 : 8;
+ vlines = (char* * ) realloc(vlines, vline_cap * sizeof(char *));
+ }
+ vlines[vline_n++] = xstrdup(line);
+}
+
+static void vreset(void) {
+ for (size_t i = 0; i < vline_n; i++)
+ free(vlines[i]);
+ free(vlines);
+ vlines = NULL;
+ vline_n = 0;
+ vline_cap = 0;
+ verbose_set(false);
+ verbose_set_sink(NULL, NULL);
+}
+
+static void test_verbose_defaults_off(void) {
+ verbose_set(false);
+ CHECK(!verbose_enabled(), "verbose defaults off");
+}
+
+static void test_log_emits_json_line_when_enabled(void) {
+ vreset();
+ verbose_set(true);
+ verbose_set_sink(vrecord, NULL);
+ vlog_kv kvs[] = {
+ VLOG_STR("name", "grep"),
+ VLOG_STR("pattern", "x"),
+ VLOG_INT("count", 42),
+ VLOG_BOOL("ok", true),
+ VLOG_DBL("ratio", 0.5),
+ };
+ verbose_log("tool_call", kvs, 5);
+ CHECK(vline_n == 1, "exactly one line emitted");
+ cJSON *v = vline_n ? cJSON_Parse(vlines[0]) : NULL;
+ CHECK(v != NULL, "emitted line is valid JSON");
+ if (v) {
+ CHECK(strcmp(cJSON_GetObjectItem(v, "event")->valuestring,
+ "tool_call") == 0,
+ "event field");
+ CHECK(strcmp(cJSON_GetObjectItem(v, "name")->valuestring, "grep") == 0,
+ "name field");
+ CHECK(strcmp(cJSON_GetObjectItem(v, "pattern")->valuestring, "x") == 0,
+ "pattern field");
+ CHECK(cJSON_GetObjectItem(v, "count")->valueint == 42, "count field");
+ CHECK(cJSON_IsTrue(cJSON_GetObjectItem(v, "ok")), "ok field");
+ CHECK(cJSON_GetObjectItem(v, "ratio")->valuedouble == 0.5,
+ "ratio field");
+ cJSON_Delete(v);
+ }
+ /* no kv at all: just the event */
+ vreset();
+ verbose_set(true);
+ verbose_set_sink(vrecord, NULL);
+ verbose_log("turn_start", NULL, 0);
+ CHECK(vline_n == 1, "event-only line emitted");
+ cJSON *v2 = vline_n ? cJSON_Parse(vlines[0]) : NULL;
+ CHECK(v2 != NULL &&
+ strcmp(cJSON_GetObjectItem(v2, "event")->valuestring,
+ "turn_start") == 0,
+ "event-only payload");
+ if (v2)
+ cJSON_Delete(v2);
+ vreset();
+}
+
+static void test_log_noop_when_disabled(void) {
+ vreset();
+ verbose_set(false);
+ verbose_set_sink(vrecord, NULL);
+ vlog_kv kvs[] = {VLOG_STR("k", "v")};
+ verbose_log("never", kvs, 1);
+ CHECK(vline_n == 0, "no output when disabled");
+ vreset();
+}
+
+int main(void) {
+ test_defaults();
+ test_set_get_roundtrip();
+ test_allowlist_allows_matching_and_refuses_others();
+ test_allowlist_entry_does_not_match_longer_word();
+ test_denylist_wins_over_allowlist();
+ test_allowlist_reason_string();
+ test_no_allowlist_means_no_allowlist();
+ test_gate_reason_wrapping();
+ test_destructive_detection();
+ test_gate_refuses_when_hook_declines();
+ test_gate_allows_when_hook_approves();
+ test_gate_no_hook_denies();
+ test_gate_non_destructive_skips_hook();
+ test_policy_null_reason();
+ test_policy_checks_wrapped_commands();
+ test_verbose_defaults_off();
+ test_log_emits_json_line_when_enabled();
+ test_log_noop_when_disabled();
+ vreset();
+ printf("test_limits: %d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_mcp.cpp b/tests/test_mcp.cpp
new file mode 100644
index 0000000..ae112ba
--- /dev/null
+++ b/tests/test_mcp.cpp
@@ -0,0 +1,579 @@
+#include "mcp.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include <fcntl.h>
+#include <signal.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include <cjson/cJSON.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+#define CHECK_EQ(a, b, msg) \
+ do { \
+ checks++; \
+ if (strcmp((a) ? (a) : "(null)", (b)) != 0) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \
+ __FILE__, __LINE__, (msg), (a) ? (a) : "(null)", (b)); \
+ } \
+ } while (0)
+
+#define CHECK_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (!hay || !strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, \
+ "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \
+ __FILE__, __LINE__, (msg), hay ? hay : "(null)", \
+ (needle)); \
+ } \
+ } while (0)
+
+static void mem_free(mcp_memory *m)
+{
+ sl_free(&m->responses);
+ sl_free(&m->sent);
+}
+
+/* helpers: push a queued JSON-RPC response / read the nth sent message */
+
+static void push_resp(mcp_memory *m, unsigned long id, const char *result)
+{
+ char *line = xstrfmt("{\"jsonrpc\":\"2.0\",\"id\":%lu,\"result\":%s}", id,
+ result);
+ sl_push(&m->responses, line);
+}
+
+static cJSON *sent_msg(mcp_memory *m, size_t i)
+{
+ if (i >= m->sent.len)
+ return NULL;
+ return cJSON_Parse(m->sent.items[i]);
+}
+
+static void test_parses_stdio_config(void)
+{
+ mcp_server_config cfg;
+ CHECK(mcp_server_config_from_json(
+ "{\"command\":\"node\",\"args\":[\"server.js\",\"--fast\"],"
+ "\"env\":{\"API_KEY\":\"k\"}}",
+ &cfg) == 1,
+ "stdio config parses");
+ CHECK_EQ(cfg.command, "node", "command");
+ CHECK(cfg.arg_count == 2, "args count");
+ CHECK(cfg.arg_count == 2 && strcmp(cfg.args[0], "server.js") == 0,
+ "arg 0");
+ CHECK(cfg.arg_count == 2 && strcmp(cfg.args[1], "--fast") == 0, "arg 1");
+ CHECK(cfg.env_count == 1 && strcmp(cfg.env[0].key, "API_KEY") == 0,
+ "env key");
+ CHECK(cfg.env_count == 1 && strcmp(cfg.env[0].val, "k") == 0, "env value");
+ CHECK(cfg.url == NULL, "no url");
+ mcp_server_config_free(&cfg);
+}
+
+static void test_parses_http_config(void)
+{
+ mcp_server_config cfg;
+ CHECK(mcp_server_config_from_json(
+ "{\"url\":\"https://example.com/mcp\","
+ "\"headers\":{\"Authorization\":\"Bearer t\"}}",
+ &cfg) == 1,
+ "http config parses");
+ CHECK_EQ(cfg.url, "https://example.com/mcp", "url");
+ CHECK(cfg.header_count == 1 &&
+ strcmp(cfg.headers[0].key, "Authorization") == 0,
+ "header key");
+ CHECK(cfg.header_count == 1 &&
+ strcmp(cfg.headers[0].val, "Bearer t") == 0,
+ "header value");
+ CHECK(cfg.command == NULL, "no command");
+ mcp_server_config_free(&cfg);
+}
+
+static void test_rejects_no_transport(void)
+{
+ mcp_server_config cfg;
+ CHECK(mcp_server_config_from_json("{\"foo\":\"bar\"}", &cfg) == 0,
+ "object without transport");
+ mcp_server_config_free(&cfg);
+ CHECK(mcp_server_config_from_json("42", &cfg) == 0, "non-object");
+ mcp_server_config_free(&cfg);
+ CHECK(mcp_server_config_from_json(NULL, &cfg) == 0, "null");
+ mcp_server_config_free(&cfg);
+}
+
+static void test_handshake_discovery_call(void)
+{
+ mcp_memory mem;
+ mcp_memory_init(&mem);
+ push_resp(&mem, 1,
+ "{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"serverInfo\":{\"name\":\"fake\",\"version\":\"0\"}}");
+ push_resp(&mem, 2,
+ "{\"tools\":[{\"name\":\"add\",\"description\":\"Add two "
+ "integers\",\"inputSchema\":{\"type\":\"object\",\"properties\":"
+ "{\"a\":{\"type\":\"integer\"},\"b\":{\"type\":\"integer\"}},"
+ "\"required\":[\"a\",\"b\"]}}]}");
+ push_resp(&mem, 3, "{\"content\":[{\"type\":\"text\",\"text\":\"7\"}]}");
+
+ char *err = NULL;
+ mcp_client *c = mcp_connect_memory(&mem, &err);
+ CHECK(c != NULL, "connect memory");
+
+ CHECK(mcp_initialize(c, &err) == 1, "handshake");
+ CHECK(err == NULL, "no handshake error");
+
+ mcp_tool *tools = NULL;
+ size_t n = 0;
+ err = NULL;
+ CHECK(mcp_list_tools(c, &tools, &n, &err) == 1, "tools/list");
+ CHECK(n == 1, "one tool");
+ if (n == 1) {
+ CHECK_EQ(tools[0].name, "add", "tool name");
+ CHECK_EQ(tools[0].description, "Add two integers", "tool description");
+ CHECK_CONT(tools[0].input_schema, "\"type\":\"object\"", "schema type");
+ CHECK_CONT(tools[0].input_schema, "\"required\"", "schema required");
+ }
+
+ err = NULL;
+ char *out = mcp_call_tool(c, "add", "{\"a\":3,\"b\":4}", &err);
+ CHECK(out != NULL, "tools/call");
+ CHECK_EQ(out, "7", "call result");
+ free(out);
+
+ /* wire order and ids must be exactly the MCP handshake */
+ CHECK(mem.sent.len == 4, "four messages sent");
+ const char *want[] = {"initialize", "notifications/initialized",
+ "tools/list", "tools/call"};
+ for (size_t i = 0; i < 4 && i < mem.sent.len; i++) {
+ cJSON *m = sent_msg(&mem, i);
+ cJSON *method = m ? cJSON_GetObjectItemCaseSensitive(m, "method") : NULL;
+ CHECK(cJSON_IsString(method) && strcmp(method->valuestring, want[i]) == 0,
+ "method order");
+ cJSON_Delete(m);
+ }
+ {
+ cJSON *m0 = sent_msg(&mem, 0);
+ cJSON *m2 = sent_msg(&mem, 2);
+ cJSON *m3 = sent_msg(&mem, 3);
+ CHECK(m0 && (unsigned long long)cJSON_GetObjectItemCaseSensitive(m0, "id")->valuedouble == 1,
+ "initialize id 1");
+ CHECK(m2 && (unsigned long long)cJSON_GetObjectItemCaseSensitive(m2, "id")->valuedouble == 2,
+ "tools/list id 2");
+ CHECK(m3 && (unsigned long long)cJSON_GetObjectItemCaseSensitive(m3, "id")->valuedouble == 3,
+ "tools/call id 3");
+ if (m3) {
+ cJSON *params = cJSON_GetObjectItemCaseSensitive(m3, "params");
+ cJSON *name = params ? cJSON_GetObjectItemCaseSensitive(params, "name") : NULL;
+ CHECK(cJSON_IsString(name) && strcmp(name->valuestring, "add") == 0,
+ "call params name");
+ cJSON *args = params ? cJSON_GetObjectItemCaseSensitive(params, "arguments") : NULL;
+ CHECK(args && (long long)cJSON_GetObjectItemCaseSensitive(args, "a")->valuedouble == 3,
+ "call params a=3");
+ }
+ cJSON_Delete(m0);
+ cJSON_Delete(m2);
+ cJSON_Delete(m3);
+ }
+ mcp_tools_free(tools, n);
+ mcp_client_free(c);
+ mem_free(&mem);
+}
+
+static void test_server_error_is_surfaced(void)
+{
+ mcp_memory mem;
+ mcp_memory_init(&mem);
+ sl_push(&mem.responses,
+ xstrdup("{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32601,"
+ "\"message\":\"Method not found\"}}"));
+ char *err = NULL;
+ mcp_client *c = mcp_connect_memory(&mem, &err);
+ err = NULL;
+ char *res = mcp_request(c, "initialize", "{}", &err);
+ CHECK(res == NULL, "request failed");
+ CHECK(err && strstr(err, "server error -32601") != NULL, "error code");
+ CHECK(err && strstr(err, "Method not found") != NULL, "error message");
+ free(err);
+ mcp_client_free(c);
+ mem_free(&mem);
+}
+
+static void test_notification_lines_are_skipped(void)
+{
+ mcp_memory mem;
+ mcp_memory_init(&mem);
+ sl_push(&mem.responses, xstrdup(
+ "{\"jsonrpc\":\"2.0\",\"method\":"
+ "\"notifications/message\",\"params\":{}}"));
+ push_resp(&mem, 1, "{\"ok\":true}");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_memory(&mem, &err);
+ err = NULL;
+ char *res = mcp_request(c, "ping", "{}", &err);
+ CHECK(res != NULL, "response past the notification");
+ CHECK_CONT(res, "\"ok\":true", "result ok");
+ free(res);
+ mcp_client_free(c);
+ mem_free(&mem);
+}
+
+static void test_tool_invoke_roundtrip(void)
+{
+ mcp_memory mem;
+ mcp_memory_init(&mem);
+ push_resp(&mem, 1,
+ "{\"tools\":[{\"name\":\"echo\",\"description\":\"echo\","
+ "\"inputSchema\":{\"type\":\"object\"}}]}");
+ push_resp(&mem, 2, "{\"content\":[{\"type\":\"text\",\"text\":\"pong\"}]}");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_memory(&mem, &err);
+
+ mcp_tool *tools = NULL;
+ size_t n = 0;
+ err = NULL;
+ CHECK(mcp_list_tools(c, &tools, &n, &err) == 1, "tools");
+ CHECK(n == 1, "one tool");
+ if (n == 1) {
+ CHECK_EQ(tools[0].name, "echo", "tool name");
+ err = NULL;
+ char *out = mcp_tool_invoke(&tools[0], "{\"msg\":\"hi\"}", &err);
+ CHECK(out != NULL, "execute");
+ CHECK_CONT(out, "pong", "server output");
+ free(out);
+ }
+ mcp_tools_free(tools, n);
+ mcp_client_free(c);
+ mem_free(&mem);
+}
+
+static void test_tool_invoke_wraps_errors(void)
+{
+ mcp_memory mem;
+ mcp_memory_init(&mem);
+ push_resp(&mem, 1,
+ "{\"tools\":[{\"name\":\"boom\",\"description\":\"\",\"inputSchema\":{\"type\":\"object\"}}]}");
+ push_resp(&mem, 2,
+ "{\"isError\":true,\"content\":[{\"type\":\"text\",\"text\":\"kaboom\"}]}");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_memory(&mem, &err);
+ mcp_tool *tools = NULL;
+ size_t n = 0;
+ err = NULL;
+ CHECK(mcp_list_tools(c, &tools, &n, &err) == 1, "tools");
+ if (n == 1) {
+ err = NULL;
+ char *out = mcp_tool_invoke(&tools[0], "{}", &err);
+ CHECK(out == NULL, "isError surfaces as failure");
+ CHECK(err && strstr(err, "MCP tool `boom` failed") != NULL,
+ "wrapped error");
+ CHECK(err && strstr(err, "kaboom") != NULL, "server text included");
+ free(err);
+ }
+ mcp_tools_free(tools, n);
+ mcp_client_free(c);
+ mem_free(&mem);
+}
+
+static void test_extract_json_plain_and_sse(void)
+{
+ char *err = NULL;
+ char *v = mcp_extract_json(
+ "application/json",
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"a\":1}}", &err);
+ CHECK(v != NULL, "plain parses");
+ CHECK_CONT(v, "\"a\":1", "plain result");
+ free(v);
+
+ const char *sse = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":2,"
+ "\"result\":{\"b\":2}}\n\n";
+ err = NULL;
+ v = mcp_extract_json("text/event-stream", sse, &err);
+ CHECK(v != NULL, "sse parses");
+ CHECK_CONT(v, "\"b\":2", "sse result");
+ free(v);
+
+ err = NULL;
+ v = mcp_extract_json("text/event-stream", "event: message\n\n", &err);
+ CHECK(v == NULL, "sse without data line fails");
+ CHECK(err && strstr(err, "data:") != NULL, "sse error mentions data:");
+ free(err);
+}
+
+static void test_connect_all_collects_warnings(void)
+{
+ const char *names[] = {"good", "bad"};
+ const char *configs[] = {
+ "{\"command\":\"/definitely/not/a/real/binary-llsh\"}",
+ "{\"foo\":\"bar\"}",
+ };
+ strlist warnings;
+ sl_init(&warnings);
+ mcp_registry reg;
+ mcp_connect_all(names, configs, 2, &reg, &warnings);
+ CHECK(reg.tool_count == 0, "no tools from unreachable servers");
+ CHECK(warnings.len == 2, "one warning per failed server");
+ if (warnings.len == 2) {
+ CHECK_CONT(warnings.items[0], "`good`", "good warning");
+ CHECK_CONT(warnings.items[1], "`bad`", "bad warning");
+ CHECK_CONT(warnings.items[1], "no `command` or `url`", "bad reason");
+ }
+ for (size_t i = 0; i < warnings.len; i++)
+ free(warnings.items[i]);
+ free(warnings.items);
+ mcp_registry_free(&reg);
+}
+
+/* ------------------------------------------------------------------ */
+/* read_line_fd (via mcp_read_line) -- buffered stdio reads */
+/* ------------------------------------------------------------------ */
+
+/* Spawn `sh -c cmd` with stdout piped; returns the read end and the pid. */
+static int spawn_sh(const char *cmd, pid_t *out_pid)
+{
+ int pfd[2];
+ if (pipe(pfd) != 0)
+ return -1;
+ pid_t pid = fork();
+ if (pid < 0) {
+ close(pfd[0]);
+ close(pfd[1]);
+ return -1;
+ }
+ if (pid == 0) {
+ dup2(pfd[1], STDOUT_FILENO);
+ close(pfd[0]);
+ close(pfd[1]);
+ int dn = open("/dev/null", O_WRONLY);
+ if (dn >= 0) {
+ dup2(dn, STDERR_FILENO);
+ close(dn);
+ }
+ setpgid(0, 0);
+ execl("/bin/sh", "sh", "-c", cmd, (char *)NULL);
+ _exit(127);
+ }
+ close(pfd[1]);
+ if (out_pid)
+ *out_pid = pid;
+ return pfd[0];
+}
+
+static void kill_sh(pid_t pid)
+{
+ if (pid <= 0)
+ return;
+ kill(-pid, SIGKILL);
+ int st;
+ waitpid(pid, &st, 0);
+}
+
+/* A burst of lines arriving in one read(): the first line must come back
+ * alone, with the rest carried for the following calls. */
+static void test_read_line_burst(void)
+{
+ pid_t pid = 0;
+ int fd = spawn_sh("printf 'a\\nb\\nc\\n'", &pid);
+ CHECK(fd >= 0, "server spawned");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_fds(-1, fd, &err);
+ CHECK(c != NULL, "fd client created");
+ char *l = mcp_read_line(c, "t", &err);
+ CHECK(l && strcmp(l, "a") == 0, "first line of the burst");
+ free(l);
+ err = NULL;
+ l = mcp_read_line(c, "t", &err);
+ CHECK(l && strcmp(l, "b") == 0, "second line of the burst");
+ free(l);
+ err = NULL;
+ l = mcp_read_line(c, "t", &err);
+ CHECK(l && strcmp(l, "c") == 0, "third line of the burst");
+ free(l);
+ err = NULL;
+ l = mcp_read_line(c, "t", &err);
+ CHECK(l == NULL, "EOF after the burst");
+ CHECK(err && strstr(err, "server closed") != NULL, "EOF error message");
+ free(err);
+ mcp_client_free(c);
+ kill_sh(pid);
+}
+
+/* A line split across two read() calls must be reassembled from the carry
+ * buffer. */
+static void test_read_line_partial_across_reads(void)
+{
+ pid_t pid = 0;
+ int fd = spawn_sh("printf 'hel'; sleep 0.5; printf 'lo\\nworld\\n'", &pid);
+ CHECK(fd >= 0, "server spawned");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_fds(-1, fd, &err);
+ char *l = mcp_read_line(c, "t", &err);
+ CHECK(l && strcmp(l, "hello") == 0, "line assembled across two reads");
+ free(l);
+ err = NULL;
+ l = mcp_read_line(c, "t", &err);
+ CHECK(l && strcmp(l, "world") == 0, "following line intact");
+ free(l);
+ mcp_client_free(c);
+ kill_sh(pid);
+}
+
+/* Timeout with bytes in flight: the partial line is what we got. */
+static void test_read_line_timeout_partial(void)
+{
+ setenv("LLSH_MCP_TIMEOUT", "1", 1);
+ pid_t pid = 0;
+ int fd = spawn_sh("printf 'partial'; sleep 5", &pid);
+ CHECK(fd >= 0, "server spawned");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_fds(-1, fd, &err);
+ char *l = mcp_read_line(c, "t", &err);
+ CHECK(l && strcmp(l, "partial") == 0, "partial line returned on timeout");
+ free(l);
+ mcp_client_free(c);
+ kill_sh(pid);
+ unsetenv("LLSH_MCP_TIMEOUT");
+}
+
+/* Timeout with a silent server: an error, not a hang or a fake line. */
+static void test_read_line_timeout_empty(void)
+{
+ setenv("LLSH_MCP_TIMEOUT", "1", 1);
+ pid_t pid = 0;
+ int fd = spawn_sh("sleep 5", &pid);
+ CHECK(fd >= 0, "server spawned");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_fds(-1, fd, &err);
+ char *l = mcp_read_line(c, "t", &err);
+ CHECK(l == NULL, "no line when the server is silent");
+ CHECK(err && strstr(err, "timed out") != NULL, "timeout error message");
+ free(err);
+ mcp_client_free(c);
+ kill_sh(pid);
+ unsetenv("LLSH_MCP_TIMEOUT");
+}
+
+static void test_stdio_line_size_limit(void)
+{
+ setenv("LLSH_MCP_TIMEOUT", "3", 1);
+ pid_t pid = 0;
+ int fd = spawn_sh("head -c 4194305 /dev/zero", &pid);
+ CHECK(fd >= 0, "large-response server spawned");
+ char *err = NULL;
+ mcp_client *c = mcp_connect_fds(-1, fd, &err);
+ char *line = mcp_read_line(c, "large", &err);
+ CHECK(line == NULL, "oversized MCP response line rejected");
+ CHECK(err && strstr(err, "4 MiB limit"), "size-limit error returned");
+ free(line);
+ free(err);
+ mcp_client_free(c);
+ kill_sh(pid);
+ unsetenv("LLSH_MCP_TIMEOUT");
+}
+
+/* ------------------------------------------------------------------ */
+/* mcp_connect_stdio -- real fork/pipe wiring */
+/* ------------------------------------------------------------------ */
+
+/* The child must be able to write to stdout and the parent to read it
+ * back: the pipe ends were historically swapped, which made every stdio
+ * server fail. A child that answers on its own (and stays alive so the
+ * parent's write to its stdin does not hit EPIPE) exercises both ends. */
+static void test_connect_stdio_roundtrip(void)
+{
+ mcp_server_config cfg;
+ memset(&cfg, 0, sizeof cfg);
+ cfg.command = xstrdup("/bin/sh");
+ cfg.args = (char **)xmalloc(2 * sizeof *cfg.args);
+ cfg.args[0] = xstrdup("-c");
+ cfg.args[1] =
+ xstrdup("printf '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"pong\"}"
+ "\\n'; sleep 1");
+ cfg.arg_count = 2;
+
+ char *err = NULL;
+ mcp_client *c = mcp_connect_stdio(&cfg, &err);
+ CHECK(c != NULL, "stdio client spawned");
+ if (!c) {
+ if (err)
+ free(err);
+ mcp_server_config_free(&cfg);
+ return;
+ }
+ char *res = mcp_request(c, "ping", "{}", &err);
+ CHECK(res != NULL, "request round-trips to the child's stdout");
+ if (res)
+ CHECK(strstr(res, "pong") != NULL, "child response read back");
+ free(res);
+ if (err)
+ free(err);
+ mcp_client_free(c);
+ mcp_server_config_free(&cfg);
+}
+
+static void test_connect_stdio_sends_newline_terminated_request(void)
+{
+ setenv("LLSH_MCP_TIMEOUT", "2", 1);
+ mcp_server_config cfg;
+ memset(&cfg, 0, sizeof cfg);
+ cfg.command = xstrdup("/bin/sh");
+ cfg.args = (char **)xmalloc(2 * sizeof *cfg.args);
+ cfg.args[0] = xstrdup("-c");
+ cfg.args[1] = xstrdup(
+ "IFS= read -r req || exit 2; printf '{\"jsonrpc\":\"2.0\","
+ "\"id\":1,\"result\":\"newline-ok\"}\\n'; sleep 1");
+ cfg.arg_count = 2;
+ char *err = NULL;
+ mcp_client *c = mcp_connect_stdio(&cfg, &err);
+ CHECK(c != NULL, "stdio client spawned");
+ char *res = c ? mcp_request(c, "ping", "{}", &err) : NULL;
+ CHECK(res && strstr(res, "newline-ok"),
+ "server receives complete newline-framed request");
+ free(res);
+ free(err);
+ mcp_client_free(c);
+ mcp_server_config_free(&cfg);
+ unsetenv("LLSH_MCP_TIMEOUT");
+}
+
+int main(void)
+{
+ /* Writes to a pipe whose server already died (e.g. a failed exec) must
+ * surface as EPIPE, not kill the process with SIGPIPE. */
+ signal(SIGPIPE, SIG_IGN);
+ test_parses_stdio_config();
+ test_parses_http_config();
+ test_rejects_no_transport();
+ test_handshake_discovery_call();
+ test_server_error_is_surfaced();
+ test_notification_lines_are_skipped();
+ test_tool_invoke_roundtrip();
+ test_tool_invoke_wraps_errors();
+ test_extract_json_plain_and_sse();
+ test_connect_all_collects_warnings();
+ test_read_line_burst();
+ test_read_line_partial_across_reads();
+ test_read_line_timeout_partial();
+ test_read_line_timeout_empty();
+ test_stdio_line_size_limit();
+ test_connect_stdio_roundtrip();
+ test_connect_stdio_sends_newline_terminated_request();
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_provider.cpp b/tests/test_provider.cpp
new file mode 100644
index 0000000..7bc983d
--- /dev/null
+++ b/tests/test_provider.cpp
@@ -0,0 +1,647 @@
+#include "provider.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "config.h"
+#include "dotagents.h"
+#include "message.h"
+#include "session.h"
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+#define CHECK_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (!hay || !strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (want substring `%s`)\n", \
+ __FILE__, __LINE__, (msg), (needle)); \
+ } \
+ } while (0)
+
+#define CHECK_NOT_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (hay && strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (found unwanted `%s`)\n", \
+ __FILE__, __LINE__, (msg), (needle)); \
+ } \
+ } while (0)
+
+static const char *SENTINEL = "SENTINEL-PREAMBLE-XYZ";
+
+static void mock_destroy(llsh_model *m) { (void)m; }
+static llsh_model MOCK = {NULL, NULL, mock_destroy};
+
+static session *make_session(const char *provider) {
+ config c;
+ memset(&c, 0, sizeof c);
+ c.provider = provider_parse(provider, NULL);
+ c.model = xstrdup("test-model");
+ c.preamble = xstrdup(SENTINEL);
+ dotagents d;
+ memset(&d, 0, sizeof d);
+ return session_new_with_model(&MOCK, c, d);
+}
+
+/* The preamble must reach the wire for every provider. Before the fix it was
+ * only emitted for Anthropic and silently dropped for OpenAI/Ollama. */
+static void test_preamble_openai(void) {
+ session *s = make_session("openai");
+ char *body = provider_preview_request(s, "hello", true);
+ CHECK(body != NULL, "openai body built");
+ /* OpenAI carries the system prompt as a leading system message. */
+ CHECK_CONT(body, "\"role\":\"system\"", "has a system message");
+ CHECK_CONT(body, SENTINEL, "system message carries the preamble");
+ CHECK_CONT(body, "\"role\":\"user\"", "user message still present");
+ CHECK_CONT(body, "hello", "prompt present");
+ free(body);
+ session_free(s);
+}
+
+static void test_preamble_ollama(void) {
+ session *s = make_session("ollama");
+ char *body = provider_preview_request(s, "hello", true);
+ CHECK(body != NULL, "ollama body built");
+ /* Ollama carries the system prompt as a top-level `system` field. */
+ CHECK_CONT(body, "\"system\":", "has a top-level system field");
+ CHECK_CONT(body, SENTINEL, "system field carries the preamble");
+ CHECK_CONT(body, "hello", "prompt present");
+ free(body);
+ session_free(s);
+}
+
+static void test_preamble_anthropic(void) {
+ session *s = make_session("anthropic");
+ char *body = provider_preview_request(s, "hello", true);
+ CHECK(body != NULL, "anthropic body built");
+ CHECK_CONT(body, "\"system\":", "has a system field");
+ CHECK_CONT(body, SENTINEL, "system carries the preamble");
+ free(body);
+ session_free(s);
+}
+
+/* A model name with JSON-special characters must not corrupt the body. */
+static void test_model_name_escaped(void) {
+ config c;
+ memset(&c, 0, sizeof c);
+ c.provider = PROV_OPENAI;
+ c.model = xstrdup("we\"ird/model");
+ c.preamble = xstrdup(SENTINEL);
+ dotagents d;
+ memset(&d, 0, sizeof d);
+ session *s = session_new_with_model(&MOCK, c, d);
+ char *body = provider_preview_request(s, "hi", false);
+ CHECK(body != NULL, "body built");
+ /* The raw `"` in the model name must be escaped to \". */
+ CHECK_CONT(body, "we\\\"ird/model", "model name quote is escaped");
+ free(body);
+ session_free(s);
+}
+
+static void test_no_preamble_omits_system(void) {
+ config c;
+ memset(&c, 0, sizeof c);
+ c.provider = PROV_OPENAI;
+ c.model = xstrdup("m");
+ c.preamble = NULL; /* no preamble */
+ dotagents d;
+ memset(&d, 0, sizeof d);
+ session *s = session_new_with_model(&MOCK, c, d);
+ char *body = provider_preview_request(s, "hi", false);
+ CHECK(body != NULL, "body built");
+ CHECK_NOT_CONT(body, "\"role\":\"system\"", "no system message without preamble");
+ free(body);
+ session_free(s);
+}
+
+/* A turn stopped mid tool-loop leaves an assistant message with more tool
+ * calls than the tool-result messages that follow it. The trim must drop the
+ * surplus calls so the history stays a valid request. */
+static void test_trim_dangling_calls(void) {
+ message am = msg_assistant_text("working on it");
+ msg_push_assistant_tool_call(am, "read_file", "{\"path\":\"a\"}");
+ msg_push_assistant_tool_call(am, "write_file", "{\"path\":\"b\"}");
+ message r1 = msg_user_tool_result("call-1", "read_file", "contents");
+ message msgs[2] = {am, r1};
+
+ CHECK(am.assistant().parts.size() == 3,
+ "assistant has text + 2 calls before trim");
+ provider_trim_dangling_calls(msgs, 2);
+ int calls = 0;
+ for (size_t i = 0; i < msgs[0].assistant().parts.size(); i++)
+ if (std::holds_alternative<asst_tool_call_part>(
+ msgs[0].assistant().parts[i]))
+ calls++;
+ CHECK(calls == 1, "surplus call trimmed to match the single result");
+
+ /* Fully-answered calls must be untouched. */
+ message am2 = msg_assistant_text("");
+ msg_push_assistant_tool_call(am2, "read_file", "{}");
+ message ra = msg_user_tool_result("c1", "read_file", "x");
+ message rb = msg_user_tool_result("c2", "write_file", "ok");
+ msg_push_assistant_tool_call(am2, "write_file", "{}");
+ message msgs2[3] = {am2, ra, rb};
+ provider_trim_dangling_calls(msgs2, 3);
+ int calls2 = 0;
+ for (size_t i = 0; i < msgs2[0].assistant().parts.size(); i++)
+ if (std::holds_alternative<asst_tool_call_part>(
+ msgs2[0].assistant().parts[i]))
+ calls2++;
+ CHECK(calls2 == 2, "complete call/result pair is preserved");
+}
+
+/* Reasoning models return the chain of thought in provider-specific fields.
+ * None of the parsers extracted it before the fix, so it was never displayed. */
+static void test_reasoning_openai(void) {
+ const char *body =
+ "{\"choices\":[{\"message\":{\"content\":\"42\","
+ "\"reasoning_content\":\"think... think... 42\"}}]}";
+ llm_conn conn; memset(&conn, 0, sizeof conn);
+ conn.provider = LLM_OPENAI; conn.model = "m";
+ llm_completion comp; memset(&comp, 0, sizeof comp);
+ CHECK(llm_parse_body(&conn, body, &comp) == 0, "openai body parses");
+ CHECK_CONT(comp.text, "42", "answer text extracted");
+ CHECK(comp.reasoning != NULL, "reasoning extracted (was NULL before fix)");
+ CHECK_CONT(comp.reasoning, "think...", "reasoning content preserved");
+ llm_completion_free(&comp);
+}
+
+static void test_reasoning_ollama(void) {
+ const char *body =
+ "{\"message\":{\"content\":\"the answer\","
+ "\"thinking\":\"pondering the answer...\"}}";
+ llm_conn conn; memset(&conn, 0, sizeof conn);
+ conn.provider = LLM_OLLAMA; conn.model = "m";
+ llm_completion comp; memset(&comp, 0, sizeof comp);
+ CHECK(llm_parse_body(&conn, body, &comp) == 0, "ollama body parses");
+ CHECK_CONT(comp.text, "the answer", "answer text extracted");
+ CHECK(comp.reasoning != NULL, "reasoning extracted from message.thinking");
+ CHECK_CONT(comp.reasoning, "pondering", "reasoning content preserved");
+ llm_completion_free(&comp);
+}
+
+static void test_reasoning_anthropic(void) {
+ const char *body =
+ "{\"content\":[{\"type\":\"thinking\",\"thinking\":\"hmm, let me "
+ "reason...\"},{\"type\":\"text\",\"text\":\"done\"}]}";
+ llm_conn conn; memset(&conn, 0, sizeof conn);
+ conn.provider = LLM_ANTHROPIC; conn.model = "m";
+ llm_completion comp; memset(&comp, 0, sizeof comp);
+ CHECK(llm_parse_body(&conn, body, &comp) == 0, "anthropic body parses");
+ CHECK_CONT(comp.text, "done", "answer text extracted");
+ CHECK(comp.reasoning != NULL, "reasoning extracted from thinking block");
+ CHECK_CONT(comp.reasoning, "reason", "reasoning content preserved");
+ llm_completion_free(&comp);
+}
+
+/* A plain (non-reasoning) response must not fabricate a reasoning field. */
+static void test_no_reasoning_when_absent(void) {
+ const char *body =
+ "{\"choices\":[{\"message\":{\"content\":\"just an answer\"}}]}";
+ llm_conn conn; memset(&conn, 0, sizeof conn);
+ conn.provider = LLM_OPENAI; conn.model = "m";
+ llm_completion comp; memset(&comp, 0, sizeof comp);
+ CHECK(llm_parse_body(&conn, body, &comp) == 0, "plain body parses");
+ CHECK_CONT(comp.text, "just an answer", "answer text extracted");
+ CHECK(comp.reasoning == NULL, "no reasoning field fabricated");
+ llm_completion_free(&comp);
+}
+
+/* ------------------------------------------------------------------ */
+/* sse_parse_line -- the pure streaming unit (AUDIT phase 2 test seam) */
+/* ------------------------------------------------------------------ */
+
+/* OpenAI: text deltas, two interleaved multi-chunk tool calls,
+ * finish_reason, [DONE], usage. */
+static void test_sse_openai(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_OPENAI);
+ llm_sse_parse_line(LLM_OPENAI, "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}", &a);
+ llm_sse_parse_line(LLM_OPENAI, "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}", &a);
+ CHECK(a.text.len == 5, "text deltas accumulate");
+ CHECK_CONT(a.text.data, "Hello", "text content");
+ llm_sse_parse_line(LLM_OPENAI,
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":["
+ "{\"index\":0,\"id\":\"c1\",\"function\":{\"name\":\"read_file\","
+ "\"arguments\":\"{\\\"pa\"}}]}}]}", &a);
+ llm_sse_parse_line(LLM_OPENAI,
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":["
+ "{\"index\":1,\"id\":\"c2\",\"function\":{\"name\":\"grep\","
+ "\"arguments\":\"{\\\"pat\"}}]}}]}", &a);
+ llm_sse_parse_line(LLM_OPENAI,
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":["
+ "{\"index\":0,\"function\":{\"arguments\":\"th\\\":\\\"a.txt\\\"}\"}}]}}]}",
+ &a);
+ llm_sse_parse_line(LLM_OPENAI,
+ "data: {\"choices\":[{\"delta\":{\"tool_calls\":["
+ "{\"index\":1,\"function\":{\"arguments\":\"tern\\\":\\\"x\\\"}\"}}]}}]}",
+ &a);
+ CHECK(a.tc_len == 2, "two tool calls indexed");
+ CHECK(a.tc_id[0] && strcmp(a.tc_id[0], "c1") == 0, "call 0 id");
+ CHECK(a.tc_id[1] && strcmp(a.tc_id[1], "c2") == 0, "call 1 id");
+ CHECK(a.tc_name[0] && strcmp(a.tc_name[0], "read_file") == 0, "call 0 name");
+ CHECK_CONT(a.tc_args[0].data, "\"path\":\"a.txt\"", "call 0 args assembled");
+ CHECK_CONT(a.tc_args[1].data, "\"pattern\":\"x\"", "call 1 args assembled");
+ llm_sse_parse_line(LLM_OPENAI,
+ "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}",
+ &a);
+ CHECK(a.done, "finish_reason marks the stream done");
+ llm_sse_parse_line(LLM_OPENAI, "data: [DONE]", &a);
+ llm_sse_parse_line(LLM_OPENAI, "data: {\"usage\":{\"prompt_tokens\":42}}", &a);
+ CHECK(a.input_tokens == 42, "usage.prompt_tokens captured");
+ llm_sse_acc_free(&a);
+}
+
+/* Anthropic: event:/data: pairing, thinking + text blocks, a tool_use
+ * block with chunked input_json_delta, message_start usage, message_stop. */
+static void test_sse_anthropic(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_ANTHROPIC);
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: message_start", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"message\":{\"usage\":{\"input_tokens\":7}}}", &a);
+ CHECK(a.input_tokens == 7, "usage from message_start");
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_start", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"content_block\":{\"type\":\"thinking\"}}", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_delta", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"hmm \"}}",
+ &a);
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_start", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"content_block\":{\"type\":\"text\"}}", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_delta", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}", &a);
+ CHECK_CONT(a.reasoning.data, "hmm", "thinking_delta captured");
+ CHECK_CONT(a.text.data, "ok", "text_delta captured");
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_start", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"content_block\":{\"type\":\"tool_use\",\"id\":\"t1\","
+ "\"name\":\"write_file\"}}", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_delta", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"delta\":{\"type\":\"input_json_delta\","
+ "\"partial_json\":\"{\\\"pa\"}}", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC,
+ "data: {\"delta\":{\"type\":\"input_json_delta\","
+ "\"partial_json\":\"th\\\":\\\"x\\\"}\"}}", &a);
+ CHECK(a.tc_len == 1, "one tool_use block");
+ CHECK(a.tc_id[0] && strcmp(a.tc_id[0], "t1") == 0, "tool_use id");
+ CHECK(a.tc_name[0] && strcmp(a.tc_name[0], "write_file") == 0, "tool_use name");
+ CHECK_CONT(a.tc_args[0].data, "\"path\":\"x\"", "chunked partial_json assembled");
+ llm_sse_parse_line(LLM_ANTHROPIC, "event: message_stop", &a);
+ llm_sse_parse_line(LLM_ANTHROPIC, "data: [DONE]", &a);
+ CHECK(a.done, "message_stop marks done");
+ llm_sse_acc_free(&a);
+}
+
+/* Ollama NDJSON: content + thinking chunks, prompt_eval_count, tool_calls
+ * on the done chunk (object arguments), done:true. */
+static void test_sse_ollama(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_OLLAMA);
+ llm_sse_parse_line(LLM_OLLAMA, "{\"message\":{\"content\":\"He\"}}", &a);
+ llm_sse_parse_line(LLM_OLLAMA, "{\"message\":{\"thinking\":\"think\"}}", &a);
+ llm_sse_parse_line(LLM_OLLAMA, "{\"message\":{\"content\":\"llo\"}}", &a);
+ CHECK_CONT(a.text.data, "Hello", "content chunks accumulate");
+ CHECK_CONT(a.reasoning.data, "think", "thinking chunk captured");
+ llm_sse_parse_line(LLM_OLLAMA, "{\"prompt_eval_count\":9}", &a);
+ CHECK(a.input_tokens == 9, "prompt_eval_count captured");
+ llm_sse_parse_line(LLM_OLLAMA,
+ "{\"message\":{\"tool_calls\":[{\"id\":\"o1\",\"function\":"
+ "{\"name\":\"list_dir\",\"arguments\":{\"path\":\".\"}}}]},"
+ "\"done\":true}", &a);
+ CHECK(a.tc_len == 1, "tool call on the done chunk");
+ CHECK(a.tc_id[0] && strcmp(a.tc_id[0], "o1") == 0, "tool id");
+ CHECK(a.tc_name[0] && strcmp(a.tc_name[0], "list_dir") == 0, "tool name");
+ CHECK_CONT(a.tc_args[0].data, "\"path\":\".\"", "object args serialized");
+ CHECK(a.done, "done:true marks done");
+ llm_sse_acc_free(&a);
+}
+
+/* Lines outside the protocol (stray text, bad JSON) are ignored without
+ * corrupting the accumulator. */
+static void test_sse_noise_lines(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_OPENAI);
+ llm_sse_parse_line(LLM_OPENAI, "event: ping", &a); /* not `data:` -> ignored */
+ llm_sse_parse_line(LLM_OPENAI, "data: not-json", &a); /* bad JSON -> ignored */
+ llm_sse_parse_line(LLM_OPENAI, "data: {\"choices\":[]}", &a); /* empty choices */
+ CHECK(a.text.len == 0 && a.tc_len == 0 && !a.done, "noise left acc empty");
+ llm_sse_acc_free(&a);
+
+ llm_sse_acc b;
+ llm_sse_acc_init(&b, LLM_ANTHROPIC);
+ llm_sse_parse_line(LLM_ANTHROPIC, "data: {\"no\":\"event\"}", &b); /* no event set */
+ llm_sse_parse_line(LLM_ANTHROPIC, "data: {", &b); /* truncated JSON */
+ CHECK(b.text.len == 0 && b.tc_len == 0 && !b.done, "anthropic noise ignored");
+ llm_sse_acc_free(&b);
+
+ llm_sse_acc c;
+ llm_sse_acc_init(&c, LLM_OLLAMA);
+ llm_sse_parse_line(LLM_OLLAMA, "hello", &c); /* not JSON */
+ llm_sse_parse_line(LLM_OLLAMA, "{}", &c); /* no message field */
+ CHECK(c.text.len == 0 && c.tc_len == 0 && !c.done, "ollama noise ignored");
+ llm_sse_acc_free(&c);
+}
+
+/* sse_acc_into: unnamed/unargued calls get default ids and `{}` args. */
+static void test_sse_acc_into_defaults(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_OLLAMA);
+ llm_acc_push_tool(&a, NULL, NULL);
+ llm_completion c;
+ memset(&c, 0, sizeof c);
+ llm_sse_acc_into(&a, &c);
+ CHECK(c.tc_len == 1, "one materialized call");
+ CHECK(c.tc_id[0] && strstr(c.tc_id[0], "call-1") != NULL, "default id");
+ CHECK(c.tc_args[0] && strcmp(c.tc_args[0], "{}") == 0, "default empty args");
+ llm_completion_free(&c);
+ llm_sse_acc_free(&a);
+}
+
+/* sse_acc_into: non-empty text/reasoning transfer ownership to `c` and must
+ * survive completion_free + sse_acc_free without a double-free. Regression:
+ * the old code freed the stolen buffer right after handing it to `c`,
+ * dangling c->text/c->reasoning (crashed on real streamed responses). */
+static void test_sse_acc_into_text_reasoning_ownership(void) {
+ llm_sse_acc a;
+ llm_sse_acc_init(&a, LLM_OPENAI);
+ llm_buf_put(&a.text, "the answer is 42");
+ llm_buf_put(&a.reasoning, "pondering deeply");
+ llm_acc_push_tool(&a, xstrdup("call-9"), xstrdup("read_file"));
+ llm_buf_put(&a.tc_args[0], "\"path\":\"AUDIT.md\"");
+
+ llm_completion c;
+ memset(&c, 0, sizeof c);
+ llm_sse_acc_into(&a, &c);
+ CHECK(c.text && strcmp(c.text, "the answer is 42") == 0,
+ "text transferred intact");
+ CHECK(c.reasoning && strcmp(c.reasoning, "pondering deeply") == 0,
+ "reasoning transferred intact");
+ CHECK(c.tc_len == 1 && c.tc_args[0] &&
+ strcmp(c.tc_args[0], "\"path\":\"AUDIT.md\"") == 0,
+ "tool args transferred intact");
+ /* Both frees must be safe: `c` owns the strings now, `a` is reset. */
+ llm_completion_free(&c);
+ llm_sse_acc_free(&a);
+}
+
+/* ================= llm_params -> request body =================
+ * The `llm` tool threads llm_params (temperature, max_tokens, reasoning
+ * effort, thinking budget, response schema) into the wire body. These are
+ * pure request-builder tests (no network) per provider. */
+
+static llm_message one_user(const char *text) {
+ static llm_part part;
+ memset(&part, 0, sizeof part);
+ part.kind = LLM_TEXT;
+ part.text = text;
+ llm_message m;
+ memset(&m, 0, sizeof m);
+ m.role = LLM_ROLE_USER;
+ m.parts = &part;
+ m.part_count = 1;
+ return m;
+}
+
+static void test_params_openai(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_OPENAI;
+ c.model = "test-model";
+ llm_params p;
+ llm_params_init(&p);
+ p.temperature = 0.5;
+ p.max_tokens = 256;
+ p.reasoning_effort = "high";
+ p.response_schema = "{\"type\":\"object\"}";
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "openai body built");
+ if (body) {
+ CHECK_CONT(body, "\"temperature\":0.5", "openai temperature");
+ CHECK_CONT(body, "\"max_tokens\":256", "openai max_tokens");
+ CHECK_CONT(body, "\"reasoning_effort\":\"high\"",
+ "openai reasoning_effort");
+ CHECK_CONT(body, "\"response_format\":{\"type\":\"json_schema\"",
+ "openai response_format");
+ CHECK_CONT(body, "\"type\":\"object\"", "openai schema inlined");
+ free(body);
+ }
+}
+
+static void test_params_ollama(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_OLLAMA;
+ c.model = "ollama-model";
+ llm_params p;
+ llm_params_init(&p);
+ p.temperature = 0.75; /* 3/4: exact in binary, so %.6g is stable */
+ p.max_tokens = 128;
+ p.reasoning_effort = "medium";
+ p.response_schema = "{\"type\":\"object\"}";
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, "SYS", &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "ollama body built");
+ if (body) {
+ CHECK_CONT(body, "\"system\":\"SYS\"", "ollama top-level system");
+ CHECK_CONT(body, "\"options\":{\"temperature\":0.75",
+ "ollama options.temperature");
+ CHECK_CONT(body, "\"num_predict\":128", "ollama num_predict");
+ CHECK_CONT(body, "\"think\":true", "ollama think enabled");
+ CHECK_CONT(body, "\"format\":", "ollama format field");
+ CHECK_CONT(body, "\"type\":\"object\"", "ollama schema");
+ free(body);
+ }
+}
+
+static void test_params_anthropic_thinking(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_ANTHROPIC;
+ c.model = "claude";
+ llm_params p;
+ llm_params_init(&p);
+ p.thinking_budget = 16384;
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "anthropic body built");
+ if (body) {
+ CHECK_CONT(body, "\"thinking\":{\"type\":\"enabled\","
+ "\"budget_tokens\":16384}",
+ "anthropic thinking budget");
+ /* max_tokens must be bumped above the thinking budget. */
+ CHECK_CONT(body, "\"max_tokens\":17408",
+ "anthropic max_tokens > budget");
+ free(body);
+ }
+}
+
+static void test_params_anthropic_effort(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_ANTHROPIC;
+ c.model = "claude";
+ llm_params p;
+ llm_params_init(&p);
+ p.reasoning_effort = "low"; /* maps to a 1024-token budget */
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "anthropic body built");
+ if (body)
+ CHECK_CONT(body, "\"budget_tokens\":1024",
+ "effort 'low' maps to a 1024 budget");
+ free(body);
+}
+
+static void test_params_anthropic_temperature(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_ANTHROPIC;
+ c.model = "claude";
+ llm_params p;
+ llm_params_init(&p);
+ p.temperature = 0.25; /* 1/4: exact in binary, no thinking budget */
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "anthropic body built");
+ if (body) {
+ CHECK_CONT(body, "\"temperature\":0.25", "anthropic temperature");
+ CHECK_NOT_CONT(body, "\"thinking\"",
+ "no thinking block without a budget");
+ free(body);
+ }
+}
+
+static void test_params_anthropic_schema(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_ANTHROPIC;
+ c.model = "claude";
+ llm_params p;
+ llm_params_init(&p);
+ p.response_schema = "{\"type\":\"object\"}";
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, "SYS", &m, 1, NULL, false, &p);
+ CHECK(body != NULL, "anthropic body built");
+ if (body) {
+ CHECK_CONT(body, "SYS", "preamble kept");
+ CHECK_CONT(body, "JSON Schema", "schema instruction appended");
+ /* The schema is JSON-encoded into the system string, so its quotes
+ * are escaped in the body. */
+ CHECK_CONT(body, "\\\"type\\\"", "schema text embedded (escaped)");
+ CHECK_NOT_CONT(body, "response_format",
+ "anthropic has no response_format");
+ free(body);
+ }
+}
+
+static void test_params_unset(void) {
+ llm_conn c;
+ memset(&c, 0, sizeof c);
+ c.provider = LLM_OPENAI;
+ c.model = "m";
+
+ llm_message m = one_user("hi");
+ char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, NULL);
+ CHECK(body != NULL, "body built with NULL params");
+ if (body) {
+ CHECK_NOT_CONT(body, "temperature", "no temperature when unset");
+ CHECK_NOT_CONT(body, "max_tokens", "no max_tokens when unset");
+ CHECK_NOT_CONT(body, "response_format",
+ "no response_format when unset");
+ free(body);
+ }
+}
+
+/* ================= `compact` built-in tool =================
+ * session_force_compact is the dispatch target for the `compact` tool call. */
+
+static void test_force_compact_short(void) {
+ session *s = make_session("openai");
+ session_push_history(s, msg_user_text("hello"));
+ session_push_history(s, msg_assistant_text("hi"));
+ compact_report rep;
+ memset(&rep, 0, sizeof rep);
+ char *err = NULL;
+ int rc = session_force_compact(s, &rep, &err);
+ /* user+assistant only: keep-boundary at index 0 -> no pass can run. */
+ CHECK(rc == 0, "no pass on a too-short history");
+ free(err);
+ session_free(s);
+}
+
+static void test_force_compact_clears(void) {
+ session *s = make_session("openai");
+ session_push_history(s, msg_user_text("read the file"));
+ session_push_history(s, msg_assistant_tool_call("read_file", "{}"));
+ session_push_history(s, msg_user_tool_result("c1", "read_file",
+ "a lot of cleared content"));
+ session_push_history(s, msg_assistant_text("done reading"));
+ session_push_history(s, msg_user_text("now edit it"));
+
+ compact_report rep;
+ memset(&rep, 0, sizeof rep);
+ char *err = NULL;
+ int rc = session_force_compact(s, &rep, &err);
+ CHECK(rc == 1, "compaction pass ran");
+ CHECK(rep.tier == TIER_FALLBACK_CLEAR,
+ "no summarizer -> fallback clear tier");
+ CHECK(rep.cleared_tool_results >= 1, "stale tool result cleared");
+ free(err);
+ session_free(s);
+}
+
+int main(void) {
+ test_preamble_openai();
+ test_preamble_ollama();
+ test_preamble_anthropic();
+ test_model_name_escaped();
+ test_no_preamble_omits_system();
+ test_trim_dangling_calls();
+ test_reasoning_openai();
+ test_reasoning_ollama();
+ test_reasoning_anthropic();
+ test_no_reasoning_when_absent();
+ test_sse_openai();
+ test_sse_anthropic();
+ test_sse_ollama();
+ test_sse_noise_lines();
+ test_sse_acc_into_defaults();
+ test_sse_acc_into_text_reasoning_ownership();
+ test_params_openai();
+ test_params_ollama();
+ test_params_anthropic_thinking();
+ test_params_anthropic_effort();
+ test_params_anthropic_temperature();
+ test_params_anthropic_schema();
+ test_params_unset();
+ test_force_compact_short();
+ test_force_compact_clears();
+ printf("provider: %d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_quine.c b/tests/test_quine.c
new file mode 100644
index 0000000..c010539
--- /dev/null
+++ b/tests/test_quine.c
@@ -0,0 +1,27 @@
+/* test_quine.c — a C quine: running it prints its own source.
+ *
+ * How it works: the file is a fixed point of a "quinate" function.
+ * The two strings below are that function's data; everything around
+ * them is code that re-emits the data verbatim, spliced back into the
+ * same program shape. No argv, no /proc/self, no tricks — just two
+ * printf calls.
+ *
+ * Build: musl-gcc -std=gnu11 -Oz -Wall -Wextra -o test_quine tests/test_quine.c
+ * Check: ./test_quine | diff - tests/test_quine.c (no output = quine) */
+
+#include <stdio.h>
+
+int main(void) {
+ const char *s =
+ "const char *s = \"%s\";\n"
+ "const char *f = \"%s\";\n"
+ "printf(f, s, s, f);\n"
+ "return 0;\n";
+ const char *f =
+ "int main(void) {\n"
+ " %s printf(f, s, s, f);\n"
+ " return 0;\n"
+ "}\n";
+ printf(f, s, s, f);
+ return 0;
+}
diff --git a/tests/test_session.cpp b/tests/test_session.cpp
new file mode 100644
index 0000000..7b893f4
--- /dev/null
+++ b/tests/test_session.cpp
@@ -0,0 +1,1659 @@
+#include "session.h"
+
+#include <atomic>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+/* ------------------------------------------------------------------ */
+/* Harness */
+/* ------------------------------------------------------------------ */
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+#define CHECK_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (!hay || !strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, \
+ "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \
+ __FILE__, __LINE__, (msg), hay ? hay : "(null)", \
+ (needle)); \
+ } \
+ } while (0)
+
+#define CHECK_NOT_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (hay && strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (found `%s` in `%s`)\n", \
+ __FILE__, __LINE__, (msg), (needle), hay); \
+ } \
+ } while (0)
+
+#define CHECK_EQ_U64(got, want, msg) \
+ do { \
+ checks++; \
+ if ((unsigned long long)(got) != (unsigned long long)(want)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got %llu, want %llu)\n", \
+ __FILE__, __LINE__, (msg), (unsigned long long)(got), \
+ (unsigned long long)(want)); \
+ } \
+ } while (0)
+
+/* ------------------------------------------------------------------ */
+/* Mock model */
+/* ------------------------------------------------------------------ */
+
+typedef struct mock_model {
+ llsh_model m;
+ /* stream */
+ int results[8];
+ size_t nresults;
+ size_t calls;
+ char *assistant;
+ unsigned long input_tokens;
+ int http_status;
+ int transport;
+ unsigned long retry_after;
+ char *errmsg;
+ /* complete */
+ char *reply;
+ int complete_fail;
+ char *complete_errmsg;
+ size_t complete_calls;
+} mock_model;
+
+static char *mock_complete(const llsh_model *mm, const char *prompt,
+ sess_error *err) {
+ (void)prompt;
+ mock_model *m = (mock_model *)mm;
+ m->complete_calls++;
+ if (m->complete_fail) {
+ if (err) {
+ err->message = xstrdup(m->complete_errmsg
+ ? m->complete_errmsg
+ : "boom");
+ err->http_status = 500;
+ err->transport = 0;
+ err->retry_after_secs = 0;
+ }
+ return NULL;
+ }
+ return xstrdup(m->reply ? m->reply : "");
+}
+
+static int mock_stream(const llsh_model *mm, const message *history,
+ size_t nhistory, const char *prompt, sess_emit_cb cb,
+ void *user, std::atomic<bool> *stop,
+ char **assistant_text, unsigned long *input_tokens,
+ message **new_messages, size_t *n_new, sess_error *err) {
+ (void)history;
+ (void)nhistory;
+ (void)prompt;
+ (void)stop;
+ mock_model *m = (mock_model *)mm;
+ size_t idx = m->calls < m->nresults ? m->calls : m->nresults - 1;
+ int result = m->results[idx];
+ m->calls++;
+ if (new_messages)
+ *new_messages = NULL;
+ if (n_new)
+ *n_new = 0;
+ if (result == SESS_STREAM_OK || result == SESS_STREAM_STOPPED) {
+ if (result == SESS_STREAM_OK) {
+ if (cb) {
+ sess_emit ev;
+ memset(&ev, 0, sizeof ev);
+ ev.kind = SESS_EMIT_TEXT;
+ ev.text = xstrdup(m->assistant ? m->assistant : "");
+ cb(&ev, user);
+ sess_emit_free(&ev);
+ }
+ if (assistant_text)
+ *assistant_text = xstrdup(m->assistant ? m->assistant : "");
+ if (input_tokens)
+ *input_tokens = m->input_tokens;
+ }
+ return result;
+ }
+ if (err) {
+ err->message = xstrdup(m->errmsg ? m->errmsg : "provider said no");
+ err->http_status = m->http_status;
+ err->transport = m->transport;
+ err->retry_after_secs = m->retry_after;
+ }
+ return result;
+}
+
+static void mock_destroy(llsh_model *self);
+
+static mock_model *mk_model(void) {
+ mock_model *m = (mock_model* ) xmalloc(sizeof *m);
+ memset(m, 0, sizeof *m);
+ m->m.complete = mock_complete;
+ m->m.stream = mock_stream;
+ m->m.destroy = mock_destroy;
+ return m;
+}
+
+static void mock_set_stream(mock_model *m, const int *results, size_t n,
+ const char *assistant, unsigned long input_tokens) {
+ size_t k = n < 8 ? n : 8;
+ for (size_t i = 0; i < k; i++)
+ m->results[i] = results[i];
+ m->nresults = k ? k : 1;
+ if (k == 0)
+ m->results[0] = SESS_STREAM_OK;
+ free(m->assistant);
+ m->assistant = xstrdup(assistant ? assistant : "");
+ m->input_tokens = input_tokens;
+}
+
+static mock_model *mk_complete(const char *reply, int fail,
+ const char *errmsg) {
+ mock_model *m = mk_model();
+ m->reply = xstrdup(reply ? reply : "");
+ m->complete_fail = fail;
+ m->complete_errmsg = xstrdup(errmsg ? errmsg : "boom");
+ return m;
+}
+
+static void mock_destroy(llsh_model *self) {
+ mock_model *m = (mock_model *)self;
+ free(m->assistant);
+ free(m->errmsg);
+ free(m->reply);
+ free(m->complete_errmsg);
+ free(m);
+}
+
+/* ------------------------------------------------------------------ */
+/* Fixtures */
+/* ------------------------------------------------------------------ */
+
+static config base_config(void) {
+ config c;
+ memset(&c, 0, sizeof c);
+ c.provider = PROV_OPENAI;
+ c.model = xstrdup("gpt-test");
+ c.api_key = xstrdup("sk-test");
+ c.max_retries = 3;
+ c.context_limit = 0; /* compaction off by default */
+ return c;
+}
+
+static dotagents empty_dot(void) {
+ dotagents d;
+ memset(&d, 0, sizeof d);
+ return d;
+}
+
+static dotagents dot_with_reviewer(void) {
+ dotagents d = empty_dot();
+ d.sub_agent_count = 1;
+ d.sub_agents = (sub_agent* ) xmalloc(sizeof(sub_agent));
+ memset(&d.sub_agents[0], 0, sizeof(sub_agent));
+ d.sub_agents[0].id = xstrdup("reviewer");
+ d.sub_agents[0].name = xstrdup("Reviewer");
+ d.sub_agents[0].description = xstrdup("Reviews code");
+ d.sub_agents[0].role = xstrdup("reviewer");
+ d.sub_agents[0].enabled = true;
+ d.sub_agents[0].body = xstrdup("You are a reviewer.");
+ d.sub_agents[0].layer = LAYER_WORKSPACE;
+ d.sub_agents[0].config = NULL;
+ return d;
+}
+
+static dotagents dot_with_profile_config(void) {
+ dotagents d = empty_dot();
+ d.sub_agent_count = 1;
+ d.sub_agents = (sub_agent* ) xmalloc(sizeof(sub_agent));
+ memset(&d.sub_agents[0], 0, sizeof(sub_agent));
+ d.sub_agents[0].id = xstrdup("rev");
+ d.sub_agents[0].name = xstrdup("Rev");
+ d.sub_agents[0].description = xstrdup("");
+ d.sub_agents[0].role = xstrdup("rev");
+ d.sub_agents[0].enabled = true;
+ d.sub_agents[0].body = xstrdup("You are a reviewer.");
+ d.sub_agents[0].layer = LAYER_WORKSPACE;
+ d.sub_agents[0].config = xstrdup(
+ "{\"model\":\"m-profile\",\"provider\":\"anthropic\",\"api_key\":\"pk\"}");
+ return d;
+}
+
+/* A profile that overrides the model/provider AND restricts the allowed
+ * built-in tools via its config JSON. */
+static dotagents dot_with_profile_tools(void) {
+ dotagents d = empty_dot();
+ d.sub_agent_count = 1;
+ d.sub_agents = (sub_agent* ) xmalloc(sizeof(sub_agent));
+ memset(&d.sub_agents[0], 0, sizeof(sub_agent));
+ d.sub_agents[0].id = xstrdup("rev");
+ d.sub_agents[0].name = xstrdup("Rev");
+ d.sub_agents[0].description = xstrdup("");
+ d.sub_agents[0].role = xstrdup("rev");
+ d.sub_agents[0].enabled = true;
+ d.sub_agents[0].body = xstrdup("You are a reviewer.");
+ d.sub_agents[0].layer = LAYER_WORKSPACE;
+ d.sub_agents[0].config = xstrdup(
+ "{\"model\":\"m-profile\",\"provider\":\"anthropic\",\"api_key\":\"pk\","
+ "\"tools\":[\"read_file\",\"grep\"]}");
+ return d;
+}
+
+static dotagents dot_with_presets(void) {
+ dotagents d = empty_dot();
+ d.has_models = true;
+ d.models.preset_count = 2;
+ d.models.presets = (model_preset* ) xmalloc(2 * sizeof(model_preset));
+ d.models.presets[0].name = xstrdup("fast");
+ d.models.presets[0].provider = xstrdup("openai");
+ d.models.presets[0].model = xstrdup("m-fast");
+ d.models.presets[0].base_url = xstrdup("https://fast.example");
+ d.models.presets[0].api_key = xstrdup("kfast");
+ d.models.presets[0].has_max_turns = false;
+ d.models.presets[0].has_context_limit = false;
+ d.models.presets[1].name = xstrdup("badprov");
+ d.models.presets[1].provider = xstrdup("wibbly");
+ d.models.presets[1].model = xstrdup("m-bad");
+ d.models.presets[1].base_url = xstrdup("https://x");
+ d.models.presets[1].api_key = xstrdup("kb");
+ d.models.presets[1].has_max_turns = false;
+ d.models.presets[1].has_context_limit = false;
+ return d;
+}
+
+/* Text of the first text-bearing part of a user/assistant message (or NULL). */
+static const char *first_text(const message *m) {
+ if (!m)
+ return NULL;
+ if (m->is_user() && !m->user().parts.empty()) {
+ const user_part &p = m->user().parts[0];
+ if (const user_text_part *t = std::get_if<user_text_part>(&p))
+ return t->text.c_str();
+ if (const user_tool_result_part *r =
+ std::get_if<user_tool_result_part>(&p))
+ if (!r->content.empty())
+ if (const trc_text *tt = std::get_if<trc_text>(&r->content[0]))
+ return tt->text.c_str();
+ } else if (m->is_assistant() && !m->assistant().parts.empty()) {
+ const assistant_part &p = m->assistant().parts[0];
+ if (const asst_text_part *t = std::get_if<asst_text_part>(&p))
+ return t->text.c_str();
+ if (const asst_reasoning_part *r = std::get_if<asst_reasoning_part>(&p))
+ return r->text.c_str();
+ }
+ return NULL;
+}
+
+/* A history with a stale tool result before a pure-text user boundary.
+ * layout: [user"first", user tool_result, user"now", assistant"b"] */
+static void push_compact_hist(session *s) {
+ session_push_history(s, msg_user_text("first"));
+ char *big = (char* ) xmalloc(600 + 1);
+ memset(big, 'x', 600);
+ big[600] = '\0';
+ message tr = msg_user_tool_result("c1", "read_file", big);
+ free(big);
+ session_push_history(s, tr);
+ session_push_history(s, msg_user_text("now"));
+ session_push_history(s, msg_assistant_text("b"));
+}
+
+/* ------------------------------------------------------------------ */
+/* Pure helpers */
+/* ------------------------------------------------------------------ */
+
+static void test_is_transient_status(void) {
+ CHECK(sess_is_transient_status(408), "408 transient");
+ CHECK(sess_is_transient_status(429), "429 transient");
+ CHECK(sess_is_transient_status(500), "500 transient");
+ CHECK(sess_is_transient_status(502), "502 transient");
+ CHECK(sess_is_transient_status(503), "503 transient");
+ CHECK(sess_is_transient_status(504), "504 transient");
+ CHECK(sess_is_transient_status(529), "529 transient");
+ CHECK(!sess_is_transient_status(400), "400 not transient");
+ CHECK(!sess_is_transient_status(401), "401 not transient");
+ CHECK(!sess_is_transient_status(403), "403 not transient");
+ CHECK(!sess_is_transient_status(404), "404 not transient");
+ CHECK(!sess_is_transient_status(418), "418 not transient");
+ CHECK(!sess_is_transient_status(0), "0 not transient");
+}
+
+static void test_backoff_secs(void) {
+ CHECK_EQ_U64(sess_backoff_secs(1), 1, "attempt 1 -> 1s");
+ CHECK_EQ_U64(sess_backoff_secs(2), 2, "attempt 2 -> 2s");
+ CHECK_EQ_U64(sess_backoff_secs(3), 4, "attempt 3 -> 4s");
+ CHECK_EQ_U64(sess_backoff_secs(4), 8, "attempt 4 -> 8s");
+ CHECK_EQ_U64(sess_backoff_secs(5), 16, "attempt 5 -> 16s");
+ CHECK_EQ_U64(sess_backoff_secs(6), 30, "attempt 6 -> 30s cap");
+ CHECK_EQ_U64(sess_backoff_secs(7), 30, "attempt 7 -> 30s cap");
+ CHECK_EQ_U64(sess_backoff_secs(10), 30, "attempt 10 -> 30s cap");
+}
+
+static void test_human_secs(void) {
+ CHECK_EQ_U64(sess_human_secs(0), 1, "0 -> 1");
+ CHECK_EQ_U64(sess_human_secs(1), 1, "1 -> 1");
+ CHECK_EQ_U64(sess_human_secs(5), 5, "5 -> 5");
+}
+
+static void test_classify_retry(void) {
+ sess_error e;
+
+ CHECK(sess_classify_retry(NULL) == NULL, "NULL -> no retry");
+
+ e.message = xstrdup("boom");
+ e.http_status = 429;
+ e.transport = 0;
+ e.retry_after_secs = 120;
+ sess_retry *r = sess_classify_retry(&e);
+ CHECK(r != NULL, "429 -> retryable");
+ CHECK_CONT(r->reason, "HTTP 429", "reason names the status");
+ CHECK_EQ_U64(r->retry_after_secs, 60, "retry-after capped at 60");
+ CHECK(r->has_retry_after, "retry-after present");
+ sess_retry_free(r);
+ free(e.message);
+
+ e.message = xstrdup("nope");
+ e.http_status = 400;
+ e.transport = 0;
+ e.retry_after_secs = 0;
+ CHECK(sess_classify_retry(&e) == NULL, "400 -> permanent");
+ free(e.message);
+
+ e.message = xstrdup("conn reset");
+ e.http_status = 0;
+ e.transport = 1;
+ e.retry_after_secs = 0;
+ sess_retry *r2 = sess_classify_retry(&e);
+ CHECK(r2 != NULL, "transport -> retryable");
+ CHECK_CONT(r2->reason, "transient connection error", "transport reason");
+ CHECK(!r2->has_retry_after, "transport has no retry-after");
+ sess_retry_free(r2);
+ free(e.message);
+
+ e.message = xstrdup("plain");
+ e.http_status = 0;
+ e.transport = 0;
+ e.retry_after_secs = 0;
+ CHECK(sess_classify_retry(&e) == NULL, "no status/transport -> no retry");
+ free(e.message);
+}
+
+static void test_context_overflow_detection(void) {
+ sess_error e;
+
+ e.message = xstrdup(
+ "request (143063 tokens) exceeds the available context size (131072 "
+ "tokens), try increasing it");
+ e.http_status = 400;
+ e.transport = 0;
+ CHECK(sess_error_is_context_overflow(&e),
+ "400 + `exceeds the available context size` -> overflow");
+ free(e.message);
+
+ e.message = xstrdup("maximum context length of 131072 tokens exceeded");
+ e.http_status = 400;
+ e.transport = 0;
+ CHECK(sess_error_is_context_overflow(&e),
+ "400 + `maximum context length` -> overflow");
+ free(e.message);
+
+ e.message = xstrdup("Request entity too large");
+ e.http_status = 413;
+ e.transport = 0;
+ CHECK(sess_error_is_context_overflow(&e), "413 -> overflow");
+ free(e.message);
+
+ e.message = xstrdup("bad request");
+ e.http_status = 400;
+ e.transport = 0;
+ CHECK(!sess_error_is_context_overflow(&e), "400 without hint -> not");
+ free(e.message);
+
+ e.message = xstrdup("exceeded budget");
+ e.http_status = 500;
+ e.transport = 0;
+ CHECK(!sess_error_is_context_overflow(&e), "5xx -> not overflow");
+ free(e.message);
+
+ e.message = xstrdup("connection reset");
+ e.http_status = 0;
+ e.transport = 1;
+ CHECK(!sess_error_is_context_overflow(&e), "transport -> not overflow");
+ free(e.message);
+
+ e.message = NULL;
+ e.http_status = 400;
+ e.transport = 0;
+ CHECK(!sess_error_is_context_overflow(&e), "no message -> not overflow");
+}
+
+static void test_assistant_emit(void) {
+ sess_emit ev;
+
+ CHECK(sess_assistant_emit("text", "hello", NULL, &ev) == 0, "text ok");
+ CHECK(ev.kind == SESS_EMIT_TEXT, "text kind");
+ CHECK(ev.text && strcmp(ev.text, "hello") == 0, "text body");
+ sess_emit_free(&ev);
+
+ CHECK(sess_assistant_emit("reasoning", "hmm", NULL, &ev) == 0,
+ "reasoning ok");
+ CHECK(ev.kind == SESS_EMIT_REASONING, "reasoning kind");
+ CHECK(ev.text && strcmp(ev.text, "hmm") == 0, "reasoning body");
+ sess_emit_free(&ev);
+
+ CHECK(sess_assistant_emit("tool_call", "run_shell",
+ "{\"command\":\"ls\"}", &ev) == 0,
+ "tool_call ok");
+ CHECK(ev.kind == SESS_EMIT_VERBATIM, "tool_call -> verbatim display");
+ CHECK_CONT(ev.text, "$ ", "run_shell renders as a shell line");
+ CHECK_CONT(ev.text, "ls", "shell line carries the command");
+ CHECK_NOT_CONT(ev.text, "# run_shell",
+ "no generic # name line for shell tools");
+ CHECK_NOT_CONT(ev.text, "command", "raw JSON key does not leak");
+ sess_emit_free(&ev);
+
+ CHECK(sess_assistant_emit("tool_call", "run_shell",
+ "{\"command\":\"\"}", &ev) == 0,
+ "run_shell empty command ok");
+ CHECK(ev.kind == SESS_EMIT_VERBATIM, "empty command -> fallback display");
+ CHECK_CONT(ev.text, "# run_shell", "fallback names the tool");
+ sess_emit_free(&ev);
+
+ CHECK(sess_assistant_emit("tool_call", "frobnicate", "{}",
+ &ev) == 0,
+ "tool_call unknown ok");
+ CHECK(ev.kind == SESS_EMIT_VERBATIM, "tool_call unknown -> verbatim");
+ CHECK_CONT(ev.text, "frobnicate", "verbatim names the tool");
+ sess_emit_free(&ev);
+
+ CHECK(sess_assistant_emit("blob", "x", NULL, &ev) == 1,
+ "unknown kind -> no emit");
+}
+
+static void test_tool_result_emit(void) {
+ sess_emit ev;
+
+ CHECK(sess_tool_result_emit("run_shell", NULL, &ev) == 1, "NULL -> no emit");
+ CHECK(sess_tool_result_emit("run_shell", "", &ev) == 1, "empty -> no emit");
+ CHECK(sess_tool_result_emit("run_shell", "ls", NULL) == 1,
+ "NULL out -> no emit");
+
+ CHECK(sess_tool_result_emit("read_file", "hello", &ev) == 0, "plain ok");
+ CHECK(ev.kind == SESS_EMIT_RESULT, "result kind");
+ CHECK(ev.from_shell, "read_file result marked shell-style");
+ CHECK(ev.text && strcmp(ev.text, "hello\n") == 0,
+ "body normalized to one trailing newline");
+ sess_emit_free(&ev);
+
+ CHECK(sess_tool_result_emit("run_shell", "a\nb\n\n\n", &ev) == 0,
+ "multiline ok");
+ CHECK(ev.from_shell, "run_shell result marked shell-style");
+ CHECK(ev.text && strcmp(ev.text, "a\nb\n") == 0,
+ "extra trailing newlines collapsed");
+ sess_emit_free(&ev);
+
+ CHECK(sess_tool_result_emit("search_memories", "hit", &ev) == 0,
+ "non-shell tool ok");
+ CHECK(!ev.from_shell, "search_memories not shell-style");
+ sess_emit_free(&ev);
+
+ CHECK(sess_tool_result_emit("run_shell", "out\n[stderr]\nboom\n[exit code 1]", &ev) == 0,
+ "shell-shaped ok");
+ CHECK(ev.from_shell, "run_shell result marked shell-style");
+ CHECK(ev.text &&
+ strcmp(ev.text, "out\n[stderr]\nboom\n[exit code 1]\n") == 0,
+ "body preserved verbatim");
+ sess_emit_free(&ev);
+}
+
+/* ------------------------------------------------------------------ */
+/* Preamble / preset / profile helpers */
+/* ------------------------------------------------------------------ */
+
+static void test_active_preset_from(void) {
+ dotagents d = empty_dot();
+ d.has_models = true;
+ d.models.default_name = xstrdup("fast");
+ char *p = session_active_preset_from(&d);
+ CHECK(p && strcmp(p, "fast") == 0, "default preset returned");
+ free(p);
+ dotagents_free(&d);
+
+ dotagents e = empty_dot();
+ CHECK(session_active_preset_from(&e) == NULL, "no models -> NULL");
+ dotagents_free(&e);
+}
+
+static void test_base_preamble_for(void) {
+ config c = base_config();
+ c.preamble = xstrdup("BASE PREAMBLE");
+
+ dotagents d = empty_dot();
+ d.system_prompt = xstrdup("SYS OVERRIDE");
+ char *r = sess_base_preamble_for(&c, &d);
+ CHECK_CONT(r, "SYS OVERRIDE", "system-prompt overrides base");
+ CHECK_NOT_CONT(r, "BASE PREAMBLE", "base not used when overridden");
+ free(r);
+ dotagents_free(&d);
+
+ dotagents e = empty_dot();
+ char *r2 = sess_base_preamble_for(&c, &e);
+ CHECK_CONT(r2, "BASE PREAMBLE", "config preamble used when no override");
+ free(r2);
+ dotagents_free(&e);
+
+ config_free(&c);
+}
+
+static void test_preamble_for_profile(void) {
+ config c = base_config();
+ c.preamble = xstrdup("BASE PREAMBLE");
+
+ dotagents d = dot_with_reviewer();
+ char *r = sess_preamble_for_profile(&d, &c, "reviewer");
+ CHECK_CONT(r, "You are a reviewer.", "profile body used");
+ free(r);
+
+ char *r2 = sess_preamble_for_profile(&d, &c, NULL);
+ CHECK_CONT(r2, "BASE PREAMBLE", "NULL profile -> base preamble");
+ free(r2);
+
+ dotagents_free(&d);
+ config_free(&c);
+}
+
+static void test_apply_overrides(void) {
+ config c; memset(&c, 0, sizeof c);
+ c.provider = PROV_OPENAI;
+ c.model = xstrdup("old");
+ char *err = NULL;
+ CHECK(sess_apply_overrides(&c, "m2", "anthropic", "https://bu", "key2",
+ true, &err) == 0,
+ "apply ok");
+ CHECK(c.model && strcmp(c.model, "m2") == 0, "model applied");
+ CHECK(c.provider == PROV_ANTHROPIC, "provider applied");
+ CHECK(c.base_url && strcmp(c.base_url, "https://bu") == 0, "base_url applied");
+ CHECK(c.api_key && strcmp(c.api_key, "key2") == 0, "api_key applied");
+ config_free(&c);
+
+ config c2; memset(&c2, 0, sizeof c2);
+ c2.provider = PROV_OPENAI;
+ err = NULL;
+ CHECK(sess_apply_overrides(&c2, "m", "wibbly", NULL, NULL, true,
+ &err) == -1,
+ "strict bad provider -> error");
+ CHECK(err && strstr(err, "unknown provider") != NULL, "strict err msg");
+ free(err);
+ config_free(&c2);
+
+ config c3; memset(&c3, 0, sizeof c3);
+ c3.provider = PROV_OPENAI;
+ c3.model = xstrdup("keep");
+ err = NULL;
+ CHECK(sess_apply_overrides(&c3, "m", "wibbly", NULL, NULL, false,
+ &err) == 0,
+ "non-strict bad provider -> skipped");
+ CHECK(c3.model && strcmp(c3.model, "m") == 0, "model still applied");
+ CHECK(c3.provider == PROV_OPENAI, "provider unchanged (skipped)");
+ config_free(&c3);
+}
+
+static void test_effective_for_profile(void) {
+ config base = base_config();
+ dotagents d = dot_with_profile_config();
+
+ config eff = sess_effective_for_profile(&base, &d, "rev");
+ CHECK(eff.model && strcmp(eff.model, "m-profile") == 0, "profile model");
+ CHECK(eff.provider == PROV_ANTHROPIC, "profile provider");
+ CHECK(eff.api_key && strcmp(eff.api_key, "pk") == 0, "profile api_key");
+ config_free(&eff);
+
+ config plain = sess_effective_for_profile(&base, &d, NULL);
+ CHECK(plain.model && strcmp(plain.model, "gpt-test") == 0,
+ "no profile -> base model");
+ CHECK(plain.provider == PROV_OPENAI, "no profile -> base provider");
+ CHECK(plain.api_key && strcmp(plain.api_key, "sk-test") == 0,
+ "no profile -> base api_key");
+ config_free(&plain);
+
+ dotagents_free(&d);
+ config_free(&base);
+}
+
+/* ------------------------------------------------------------------ */
+/* Turn */
+/* ------------------------------------------------------------------ */
+
+static void test_stop_before_start(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int ok_results[] = {SESS_STREAM_OK};
+ mock_set_stream(m, ok_results, 1, "unused", 0);
+
+ session *s = session_new_with_model(&m->m, c, d);
+ std::atomic<bool> stop(true);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == 0,
+ "stopped turn returns 0");
+ CHECK(result == SESS_TURN_STOPPED, "result is Stopped");
+ CHECK_EQ_U64(session_history_len(s), 1, "prompt kept in context");
+ const message *u = session_history_at(s, 0);
+ CHECK(u && u->is_user() && u->user().parts.size() == 1 &&
+ std::get<user_text_part>(u->user().parts[0]).text == "hi",
+ "kept prompt is the user text");
+ CHECK(m->calls == 0, "model not called when stopped");
+ session_free(s);
+}
+
+static void test_completed_turn_updates_history(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int ok_results[] = {SESS_STREAM_OK};
+ mock_set_stream(m, ok_results, 1, "hello there", 42);
+
+ session *s = session_new_with_model(&m->m, c, d);
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == 0,
+ "completed turn returns 0");
+ CHECK(result == SESS_TURN_COMPLETED, "result is Completed");
+ CHECK_EQ_U64(session_history_len(s), 2, "user + assistant recorded");
+ CHECK_EQ_U64(session_last_context_tokens(s), 42, "provider usage recorded");
+ const message *a = session_history_at(s, 1);
+ CHECK(a && a->is_assistant() && a->assistant().parts.size() == 1 &&
+ std::get<asst_text_part>(a->assistant().parts[0]).text ==
+ "hello there",
+ "assistant text recorded");
+ char *last = session_last_assistant_text(s);
+ CHECK(last && strcmp(last, "hello there") == 0, "last assistant text");
+ free(last);
+ session_free(s);
+}
+
+static void test_retry_then_success(void) {
+ config c = base_config();
+ c.max_retries = 3;
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int seq[] = {SESS_STREAM_RETRYABLE, SESS_STREAM_OK};
+ mock_set_stream(m, seq, 2, "recovered", 10);
+ m->http_status = 429;
+ m->errmsg = xstrdup("rate limited");
+
+ session *s = session_new_with_model(&m->m, c, d);
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == 0,
+ "retry then success returns 0");
+ CHECK(result == SESS_TURN_COMPLETED, "result is Completed");
+ CHECK(m->calls == 2, "model called twice");
+ CHECK_EQ_U64(session_history_len(s), 2, "history updated after success");
+ CHECK(err == NULL, "no error surfaced");
+ session_free(s);
+}
+
+static void test_gives_up_after_retries(void) {
+ config c = base_config();
+ c.max_retries = 2;
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int seq[] = {SESS_STREAM_RETRYABLE};
+ mock_set_stream(m, seq, 1, "never", 0);
+ m->http_status = 429;
+ m->errmsg = xstrdup("rate limited");
+
+ session *s = session_new_with_model(&m->m, c, d);
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == -1,
+ "exhausted retries returns -1");
+ CHECK(m->calls == 3, "2 retries + 1 initial = 3 calls");
+ CHECK(err && strstr(err, "provider error after 2 retries") != NULL,
+ "error names retry count");
+ CHECK(err && strstr(err, "rate limited") != NULL, "error carries cause");
+ free(err);
+ CHECK_EQ_U64(session_history_len(s), 0, "failed turn not committed");
+ session_free(s);
+}
+
+static void test_no_retry_on_permanent(void) {
+ config c = base_config();
+ c.max_retries = 3;
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int seq[] = {SESS_STREAM_FAILED};
+ mock_set_stream(m, seq, 1, "never", 0);
+ m->http_status = 400;
+ m->errmsg = xstrdup("bad request");
+
+ session *s = session_new_with_model(&m->m, c, d);
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == -1,
+ "permanent failure returns -1");
+ CHECK(m->calls == 1, "no retries for permanent failure");
+ CHECK(err && strstr(err, "stream error") != NULL, "stream error message");
+ CHECK(err && strstr(err, "bad request") != NULL, "error carries cause");
+ free(err);
+ CHECK_EQ_U64(session_history_len(s), 0, "failed turn not committed");
+ session_free(s);
+}
+
+static void test_retry_disabled(void) {
+ config c = base_config();
+ c.max_retries = 0;
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int seq[] = {SESS_STREAM_RETRYABLE};
+ mock_set_stream(m, seq, 1, "never", 0);
+ m->http_status = 429;
+ m->errmsg = xstrdup("rate limited");
+
+ session *s = session_new_with_model(&m->m, c, d);
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == -1,
+ "retry disabled returns -1");
+ CHECK(m->calls == 1, "only one attempt when retries disabled");
+ CHECK(err && strstr(err, "provider error after 0 retries") != NULL,
+ "error names 0 retries");
+ free(err);
+ session_free(s);
+}
+
+static void test_overflow_recover_by_compacting(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5; /* budget 500 */
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *sum = mk_complete("EARLIER SUMMARY", 0, NULL);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+
+ push_compact_hist(s);
+ session_set_last_context_tokens(s, 143063);
+ int seq[] = {SESS_STREAM_FAILED, SESS_STREAM_OK};
+ mock_set_stream(main, seq, 2, "recovered", 42);
+ main->http_status = 400;
+ main->errmsg = xstrdup(
+ "request (143063 tokens) exceeds the available context size (131072 "
+ "tokens), try increasing it");
+
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "go", &stop, NULL, NULL, &err, &result) == 0,
+ "overflow -> compact -> retry succeeds");
+ CHECK(result == SESS_TURN_COMPLETED, "turn completed after recovery");
+ CHECK(main->calls == 2, "model called twice (fail + retry)");
+ CHECK_EQ_U64(sum->complete_calls, 1, "summarizer compacted the prefix");
+ CHECK(err == NULL, "no error surfaced");
+ CHECK_EQ_U64(session_history_len(s), 5, "summary + kept tail + new turn");
+ const message *sum0 = session_history_at(s, 0);
+ CHECK(sum0 && sum0->is_user(),
+ "first message is the summary user message");
+ CHECK_CONT(first_text(sum0), "EARLIER SUMMARY", "summary body present");
+ session_free(s);
+}
+
+static void test_overflow_fallback_clear_still_recovers(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5;
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *sum = mk_complete(NULL, 1, "summarizer down");
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+
+ push_compact_hist(s);
+ session_set_last_context_tokens(s, 143063);
+ int seq[] = {SESS_STREAM_FAILED, SESS_STREAM_OK};
+ mock_set_stream(main, seq, 2, "recovered", 42);
+ main->http_status = 400;
+ main->errmsg = xstrdup("context window exceeded");
+
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "go", &stop, NULL, NULL, &err, &result) == 0,
+ "overflow -> fallback clear -> retry succeeds");
+ CHECK(result == SESS_TURN_COMPLETED, "turn completed");
+ CHECK_EQ_U64(sum->complete_calls, 1, "summarizer attempted");
+ CHECK(err == NULL, "no error surfaced");
+ CHECK_EQ_U64(session_history_len(s), 6,
+ "stale cleared in place, kept tail + new turn retained");
+ const message *tr = session_history_at(s, 1);
+ CHECK(tr && tr->is_user() && first_text(tr) &&
+ strstr(first_text(tr), "tool output cleared") != NULL,
+ "stale result cleared by the fallback");
+ session_free(s);
+}
+
+static void test_overflow_retries_only_once(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5;
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *sum = mk_complete("S", 0, NULL);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+ push_compact_hist(s);
+ session_set_last_context_tokens(s, 143063);
+ int seq[] = {SESS_STREAM_FAILED};
+ mock_set_stream(main, seq, 1, "never", 0);
+ main->http_status = 400;
+ main->errmsg = xstrdup("context window exceeded");
+
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "go", &stop, NULL, NULL, &err, &result) == -1,
+ "still overflowing after compact -> turn fails");
+ CHECK(main->calls == 2, "exactly one compact-and-retry");
+ CHECK(err && strstr(err, "stream error") != NULL, "error surfaced");
+ CHECK(err && strstr(err, "context window exceeded") != NULL,
+ "provider message preserved");
+ free(err);
+ session_free(s);
+}
+
+static void test_overflow_no_recovery_without_limit(void) {
+ config c = base_config();
+ c.context_limit = 0; /* compaction disabled */
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int seq[] = {SESS_STREAM_FAILED};
+ mock_set_stream(m, seq, 1, "never", 0);
+ m->http_status = 400;
+ m->errmsg = xstrdup("context window exceeded");
+
+ session *s = session_new_with_model(&m->m, c, d);
+ push_compact_hist(s);
+ session_set_last_context_tokens(s, 143063);
+
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "go", &stop, NULL, NULL, &err, &result) == -1,
+ "no limit -> overflow surfaces as stream error");
+ CHECK(m->calls == 1, "no recovery attempt without a context limit");
+ CHECK(err && strstr(err, "context window exceeded") != NULL,
+ "provider message preserved");
+ free(err);
+ CHECK_EQ_U64(session_history_len(s), 4, "history untouched");
+ session_free(s);
+}
+
+static void test_overflow_no_stale_prefix_no_recovery(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5;
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *sum = mk_complete("S", 0, NULL);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+ session_push_history(s, msg_user_text("only prompt"));
+ int seq[] = {SESS_STREAM_FAILED};
+ mock_set_stream(main, seq, 1, "never", 0);
+ main->http_status = 400;
+ main->errmsg = xstrdup("context window exceeded");
+
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "go", &stop, NULL, NULL, &err, &result) == -1,
+ "nothing to compact -> turn fails");
+ CHECK(main->calls == 2, "compact pass ran, retry still failed");
+ CHECK_EQ_U64(sum->complete_calls, 0,
+ "no stale prefix -> summarizer not called");
+ CHECK(err && strstr(err, "context window exceeded") != NULL,
+ "provider message preserved");
+ free(err);
+ session_free(s);
+}
+
+static void test_records_usage(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int seq[] = {SESS_STREAM_OK};
+ mock_set_stream(m, seq, 1, "ok", 1234);
+
+ session *s = session_new_with_model(&m->m, c, d);
+ std::atomic<bool> stop(false);
+ int result = -1;
+ char *err = NULL;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == 0,
+ "turn ok");
+ CHECK_EQ_U64(session_last_context_tokens(s), 1234, "usage recorded");
+
+ /* zero input tokens should not clobber */
+ session_clear_history(s);
+ session_set_last_context_tokens(s, 0);
+ int seq0[] = {SESS_STREAM_OK};
+ mock_set_stream(m, seq0, 1, "ok", 0);
+ CHECK(session_run_turn(s, "again", &stop, NULL, NULL, &err, &result) == 0,
+ "turn 2 ok");
+ CHECK_EQ_U64(session_last_context_tokens(s), 0, "zero tokens -> stays 0");
+ session_free(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* Model / profile */
+/* ------------------------------------------------------------------ */
+
+static void test_set_profile(void) {
+ config c = base_config();
+ dotagents d = dot_with_reviewer();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ char *err = NULL;
+
+ CHECK(session_set_profile(s, "reviewer", &err) == 0, "set known profile");
+ char *pn = session_profile_name(s);
+ CHECK(pn && strcmp(pn, "reviewer") == 0, "profile name set");
+ free(pn);
+
+ err = NULL;
+ CHECK(session_set_profile(s, "nope", &err) == -1, "unknown profile fails");
+ CHECK(err && strstr(err, "no enabled sub-agent `nope`") != NULL,
+ "unknown profile err names id");
+ free(err);
+
+ CHECK(session_set_profile(s, NULL, &err) == 0, "clear profile ok");
+ CHECK(session_profile_name(s) == NULL, "profile cleared");
+
+ session_free(s);
+}
+
+static void test_set_model_preset(void) {
+ config c = base_config();
+ dotagents d = dot_with_presets();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ char *err = NULL;
+
+ CHECK(session_set_model(s, "fast", &err) == 0, "apply preset");
+ CHECK(strcmp(session_config(s)->model, "m-fast") == 0, "preset model");
+ CHECK(strcmp(session_config(s)->base_url, "https://fast.example") == 0,
+ "preset base_url");
+ CHECK(strcmp(session_config(s)->api_key, "kfast") == 0, "preset api_key");
+ char *ap = session_active_preset(s);
+ CHECK(ap && strcmp(ap, "fast") == 0, "active preset set");
+ free(ap);
+
+ CHECK(session_set_model(s, "custom-model", &err) == 0, "raw model ok");
+ CHECK(strcmp(session_config(s)->model, "custom-model") == 0, "raw model set");
+ CHECK(session_active_preset(s) == NULL, "raw model clears preset");
+
+ err = NULL;
+ CHECK(session_set_model(s, "badprov", &err) == -1,
+ "preset with bad provider fails (strict)");
+ CHECK(err && strstr(err, "unknown provider") != NULL, "bad provider err");
+ free(err);
+
+ session_free(s);
+}
+
+static void test_current_preamble_switches_with_profile(void) {
+ config c = base_config();
+ c.preamble = xstrdup("BASE PREAMBLE");
+ dotagents d = dot_with_reviewer();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ char *err = NULL;
+
+ CHECK_CONT(session_current_preamble(s), "BASE PREAMBLE",
+ "base preamble before profile");
+
+ CHECK(session_set_profile(s, "reviewer", &err) == 0, "set profile");
+ CHECK_CONT(session_current_preamble(s), "You are a reviewer.",
+ "profile preamble after switch");
+
+ session_free(s);
+}
+
+/* The per-profile effective state (model, preamble, allowed tools) is
+ * cached on the session. Switching profiles must invalidate the cache so
+ * none of the three is served stale -- in either direction. */
+static void test_profile_cache_invalidates(void) {
+ config c = base_config();
+ c.preamble = xstrdup("BASE PREAMBLE");
+ dotagents d = dot_with_profile_tools();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ char *err = NULL;
+
+ /* Baseline (no profile): base model, base preamble, no tool restriction. */
+ char *model = session_model(s);
+ CHECK_CONT(model, "gpt-test", "base model before profile");
+ free(model);
+ CHECK_CONT(session_current_preamble(s), "BASE PREAMBLE",
+ "base preamble before profile");
+ CHECK(session_allowed_tools(s)->len == 0, "no tool restriction at base");
+
+ /* Activate the profile: all three must reflect the profile, not the
+ * previously cached base state. */
+ CHECK(session_set_profile(s, "rev", &err) == 0, "set profile");
+ model = session_model(s);
+ CHECK_CONT(model, "m-profile", "profile model after switch");
+ CHECK_NOT_CONT(model, "gpt-test", "base model not leaked");
+ free(model);
+ CHECK_CONT(session_current_preamble(s), "You are a reviewer.",
+ "profile preamble after switch");
+ CHECK_NOT_CONT(session_current_preamble(s), "BASE PREAMBLE",
+ "base preamble not leaked");
+ const strlist *allowed = session_allowed_tools(s);
+ CHECK(allowed->len == 2, "profile restricts tools to two");
+ CHECK(allowed->len == 2 &&
+ xstreq(allowed->items[0], "read_file") &&
+ xstreq(allowed->items[1], "grep"),
+ "restricted tool set is correct");
+
+ /* Clear the profile: back to the base state, not the stale profile. */
+ CHECK(session_set_profile(s, NULL, &err) == 0, "clear profile");
+ model = session_model(s);
+ CHECK_CONT(model, "gpt-test", "base model restored");
+ CHECK_NOT_CONT(model, "m-profile", "profile model not leaked");
+ free(model);
+ CHECK_CONT(session_current_preamble(s), "BASE PREAMBLE",
+ "base preamble restored");
+ CHECK_NOT_CONT(session_current_preamble(s), "You are a reviewer.",
+ "profile preamble not leaked");
+ CHECK(session_allowed_tools(s)->len == 0, "tool restriction lifted");
+
+ session_free(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* Compaction */
+/* ------------------------------------------------------------------ */
+
+static void test_compact_disabled_at_zero_limit(void) {
+ config c = base_config();
+ c.context_limit = 0;
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ push_compact_hist(s);
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *err = NULL;
+ CHECK(session_compact_if_needed(s, false, &report, &err) == 0,
+ "zero limit -> no pass");
+ CHECK(err == NULL, "no error");
+ CHECK_EQ_U64(session_history_len(s), 4, "history untouched");
+ session_free(s);
+}
+
+static void test_compact_below_thresholds(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5; /* budget 500 */
+ c.clear_threshold = 0.1; /* budget 100 */
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ push_compact_hist(s);
+ session_set_last_context_tokens(s, 50); /* below both */
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *err = NULL;
+ CHECK(session_compact_if_needed(s, false, &report, &err) == 0,
+ "below thresholds -> no pass");
+ CHECK_EQ_U64(session_history_len(s), 4, "history untouched");
+ session_free(s);
+}
+
+static void test_compact_clears_stale_tools(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5; /* budget 500 */
+ c.clear_threshold = 0.1; /* budget 100 */
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ push_compact_hist(s);
+ session_set_last_context_tokens(s, 200); /* >=100, <500 */
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *err = NULL;
+ CHECK(session_compact_if_needed(s, false, &report, &err) == 1,
+ "clear tier ran");
+ CHECK(report.tier == TIER_CLEARED, "tier is CLEARED");
+ CHECK_EQ_U64(report.cleared_tool_results, 1, "one stale result cleared");
+ CHECK(report.tokens_after < report.tokens_before, "tokens shrank");
+ CHECK_EQ_U64(session_last_context_tokens(s), 0, "usage reset after pass");
+ session_free(s);
+}
+
+static void test_compact_summarizes_at_threshold(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5;
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *sum = mk_complete("EARLIER SUMMARY", 0, NULL);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+ push_compact_hist(s);
+ session_set_last_context_tokens(s, 600); /* >=500 */
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *err = NULL;
+ CHECK(session_compact_if_needed(s, false, &report, &err) == 1,
+ "summarize tier ran");
+ CHECK(report.tier == TIER_SUMMARIZED, "tier is SUMMARIZED");
+ CHECK_EQ_U64(report.files_reinjected, 0, "no files listed -> none");
+ CHECK_EQ_U64(sum->complete_calls, 1, "summarizer called once");
+ CHECK_EQ_U64(session_history_len(s), 3, "summary + tail (2) = 3");
+ const message *sum0 = session_history_at(s, 0);
+ CHECK(sum0 && sum0->is_user(), "first is the summary user message");
+ const message *kept = session_history_at(s, 1);
+ CHECK(kept && kept->is_user() && kept->user().parts.size() == 1 &&
+ std::get<user_text_part>(kept->user().parts[0]).text == "now",
+ "tail preserved");
+ session_free(s);
+}
+
+static void test_forced_compact_reinjects_files(void) {
+ const char *path = "/tmp/opencode/llsh_reinject.txt";
+ char *content = xstrdup("REINJECTED FILE CONTENT");
+ CHECK(write_file_from_buf(path, content, strlen(content), NULL) == 0,
+ "wrote temp file");
+ free(content);
+
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5;
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ char *sumreply = xstrfmt("SUMMARY\n## Files\n- %s\n", path);
+ mock_model *sum = mk_complete(sumreply, 0, NULL);
+ free(sumreply);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+ push_compact_hist(s);
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *err = NULL;
+ CHECK(session_compact_if_needed(s, true, &report, &err) == 1, "forced ran");
+ CHECK(report.tier == TIER_SUMMARIZED, "tier is SUMMARIZED");
+ CHECK_EQ_U64(report.files_reinjected, 1, "one file re-injected");
+ const message *sum0 = session_history_at(s, 0);
+ CHECK_CONT(first_text(sum0), "REINJECTED FILE CONTENT",
+ "file content inlined");
+ CHECK_CONT(first_text(sum0), path, "file path shown");
+ unlink(path);
+ session_free(s);
+}
+
+static void test_forced_compact_fallback_clear(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5;
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *sum = mk_complete(NULL, 1, "summarizer exploded");
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+ push_compact_hist(s);
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *err = NULL;
+ CHECK(session_compact_if_needed(s, true, &report, &err) == 1,
+ "forced pass ran");
+ CHECK(report.tier == TIER_FALLBACK_CLEAR, "tier is FALLBACK_CLEAR");
+ CHECK_EQ_U64(sum->complete_calls, 1, "summarizer attempted");
+ CHECK_EQ_U64(session_history_len(s), 4, "history not rebuilt (in place)");
+ const message *tr = session_history_at(s, 1);
+ CHECK_CONT(first_text(tr), "tool output cleared",
+ "stale result replaced with placeholder");
+ session_free(s);
+}
+
+static void test_forced_compact_noop_no_stale(void) {
+ config c = base_config();
+ c.context_limit = 1000;
+ c.compact_threshold = 0.5;
+ c.clear_threshold = 0.1;
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *sum = mk_complete("S", 0, NULL);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_summarizer(s, &sum->m);
+ session_push_history(s, msg_user_text("first")); /* boundary would be 0 */
+ session_push_history(s, msg_assistant_text("b"));
+ compact_report report;
+ memset(&report, 0, sizeof report);
+ char *err = NULL;
+ CHECK(session_compact_if_needed(s, true, &report, &err) == 0,
+ "no stale prefix -> no pass");
+ CHECK_EQ_U64(sum->complete_calls, 0, "summarizer not called");
+ CHECK_EQ_U64(session_history_len(s), 2, "history untouched");
+ session_free(s);
+}
+
+static void test_undo_last_turn(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ session_push_history(s, msg_user_text("first"));
+ session_push_history(s, msg_assistant_text("a"));
+ session_push_history(s, msg_user_text("second"));
+ session_push_history(s, msg_assistant_text("b"));
+
+ CHECK(session_undo_last_turn(s) == 2, "removes most recent prompt + reply");
+ CHECK_EQ_U64(session_history_len(s), 2, "earlier turn retained");
+ const message *u0 = session_history_at(s, 0);
+ CHECK(u0 && u0->is_user() &&
+ std::get<user_text_part>(u0->user().parts[0]).text == "first",
+ "first prompt retained");
+
+ /* no pure-text user -> nothing to remove */
+ mock_model *m2 = mk_model();
+ session *s2 = session_new_with_model(&m2->m, base_config(), empty_dot());
+ session_push_history(s2, msg_assistant_text("a"));
+ session_push_history(s2,
+ msg_user_tool_result("c1", "read_file", "body"));
+ CHECK(session_undo_last_turn(s2) == -1, "no pure-text user -> -1");
+ CHECK_EQ_U64(session_history_len(s2), 2, "history untouched");
+ session_free(s2);
+
+ session_free(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* Goal */
+/* ------------------------------------------------------------------ */
+
+static void test_goal_met(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *eval = mk_complete("MET\nAll tests pass and are green.", 0,
+ NULL);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_goal_evaluator(s, &eval->m);
+ goal_spec spec;
+ spec.criteria = xstrdup("all tests pass");
+ spec.max_tries = 5;
+ goal_verdict v;
+ char *err = NULL;
+ CHECK(session_evaluate_goal(s, &spec, "I ran the suite", &v, &err) == 0,
+ "evaluate ok");
+ CHECK(v.met, "verdict met");
+ CHECK_EQ_U64(eval->complete_calls, 1, "evaluator called once");
+ free(v.reason);
+ goal_spec_free(&spec);
+ session_free(s);
+}
+
+static void test_goal_not_met(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *eval = mk_complete("NOT MET\nTwo tests still fail.", 0,
+ NULL);
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_goal_evaluator(s, &eval->m);
+ goal_spec spec;
+ spec.criteria = xstrdup("all tests pass");
+ spec.max_tries = 5;
+ goal_verdict v;
+ char *err = NULL;
+ CHECK(session_evaluate_goal(s, &spec, "I ran the suite", &v, &err) == 0,
+ "evaluate ok");
+ CHECK(!v.met, "verdict not met");
+ free(v.reason);
+ goal_spec_free(&spec);
+ session_free(s);
+}
+
+static void test_goal_no_evaluator(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ session *s = session_new_with_model(&main->m, c, d);
+ goal_spec spec;
+ spec.criteria = xstrdup("all tests pass");
+ spec.max_tries = 5;
+ goal_verdict v;
+ char *err = NULL;
+ CHECK(session_evaluate_goal(s, &spec, "done", &v, &err) == -1,
+ "no evaluator -> -1");
+ CHECK(err && strstr(err, "no goal evaluator") != NULL, "err names cause");
+ free(err);
+ goal_spec_free(&spec);
+ session_free(s);
+}
+
+static void test_goal_evaluator_error(void) {
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *main = mk_model();
+ mock_model *eval = mk_complete(NULL, 1, "evaluator exploded");
+ session *s = session_new_with_model(&main->m, c, d);
+ s = session_with_goal_evaluator(s, &eval->m);
+ goal_spec spec;
+ spec.criteria = xstrdup("all tests pass");
+ spec.max_tries = 5;
+ goal_verdict v;
+ char *err = NULL;
+ CHECK(session_evaluate_goal(s, &spec, "done", &v, &err) == -1,
+ "evaluator error -> -1");
+ CHECK(err && strstr(err, "asking the goal evaluator") != NULL,
+ "err names cause");
+ free(err);
+ goal_spec_free(&spec);
+ session_free(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* Save / load / resume */
+/* ------------------------------------------------------------------ */
+
+static void test_save_resume_roundtrip(void) {
+ const char *path = "/tmp/opencode/llsh_save.json";
+ unlink(path);
+
+ config c = base_config();
+ free(c.model);
+ c.model = xstrdup("m1");
+ free(c.api_key);
+ c.api_key = xstrdup("k");
+ c.context_limit = 500;
+ dotagents d = dot_with_reviewer();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ char *err = NULL;
+ CHECK(session_set_profile(s, "reviewer", &err) == 0, "set profile");
+ session_push_history(s, msg_user_text("hi"));
+ session_push_history(s, msg_assistant_text("yo"));
+ session_set_last_context_tokens(s, 777);
+
+ CHECK(session_save(s, path, &err) == 0, "save ok");
+ CHECK(err == NULL, "save no error");
+
+ session_snapshot *snap = session_load(path, &err);
+ CHECK(snap != NULL, "load ok");
+ if (snap) {
+ CHECK(strcmp(snap->cfg.model, "m1") == 0, "config model restored");
+ CHECK(snap->cfg.provider == PROV_OPENAI, "config provider restored");
+ CHECK(strcmp(snap->cfg.api_key, "k") == 0, "config api_key restored");
+ CHECK_EQ_U64(snap->cfg.context_limit, 500,
+ "config context_limit restored");
+ CHECK_EQ_U64(snap->hist.msgs.size(), 2, "history length restored");
+ CHECK(snap->hist.msgs[0].is_user() &&
+ std::get<user_text_part>(
+ snap->hist.msgs[0].user().parts[0])
+ .text == "hi",
+ "user message restored");
+ CHECK(snap->hist.msgs[1].is_assistant() &&
+ std::get<asst_text_part>(
+ snap->hist.msgs[1].assistant().parts[0])
+ .text == "yo",
+ "assistant message restored");
+ CHECK(snap->profile && strcmp(snap->profile, "reviewer") == 0,
+ "profile restored");
+ CHECK_EQ_U64(snap->last_context_tokens, 777, "usage restored");
+
+ dotagents d2 = dot_with_reviewer();
+ mock_model *m2 = mk_model();
+ session *s2 = session_resume(snap, d2, &m2->m, &err);
+ CHECK(s2 != NULL, "resume ok");
+ if (s2) {
+ CHECK(strcmp(session_config(s2)->model, "m1") == 0,
+ "resumed config model");
+ char *pn = session_profile_name(s2);
+ CHECK(pn && strcmp(pn, "reviewer") == 0, "resumed profile");
+ free(pn);
+ CHECK_EQ_U64(session_history_len(s2), 2, "resumed history");
+ CHECK_EQ_U64(session_last_context_tokens(s2), 777,
+ "resumed usage");
+ session_free(s2);
+ }
+ }
+ unlink(path);
+ session_free(s);
+}
+
+static void test_resume_falls_back_to_main(void) {
+ const char *path = "/tmp/opencode/llsh_save_fb.json";
+ unlink(path);
+
+ config c = base_config();
+ dotagents d = dot_with_reviewer();
+ mock_model *m = mk_model();
+ session *s = session_new_with_model(&m->m, c, d);
+ char *err = NULL;
+ CHECK(session_set_profile(s, "reviewer", &err) == 0, "set profile");
+ session_push_history(s, msg_user_text("hi"));
+ CHECK(session_save(s, path, &err) == 0, "save ok");
+
+ session_snapshot *snap = session_load(path, &err);
+ CHECK(snap != NULL, "load ok");
+ if (snap) {
+ dotagents d2 = empty_dot(); /* no 'reviewer' here */
+ mock_model *m2 = mk_model();
+ session *s2 = session_resume(snap, d2, &m2->m, &err);
+ CHECK(s2 != NULL, "resume ok");
+ if (s2) {
+ CHECK(session_profile_name(s2) == NULL,
+ "profile fell back to main agent");
+ CHECK_EQ_U64(session_history_len(s2), 1, "history still restored");
+ session_free(s2);
+ }
+ }
+ unlink(path);
+ session_free(s);
+}
+
+static void test_load_rejects_bad_version(void) {
+ const char *path = "/tmp/opencode/llsh_badver.json";
+ const char *body =
+ "{\"version\": 999, \"history\": [], \"config\": {}}";
+ CHECK(write_file_from_buf(path, body, strlen(body), NULL) == 0,
+ "wrote bad-version file");
+ char *err = NULL;
+ session_snapshot *snap = session_load(path, &err);
+ CHECK(snap == NULL, "bad version rejected");
+ CHECK(err && strstr(err, "unsupported session version") != NULL,
+ "err names the problem");
+ free(err);
+ unlink(path);
+}
+
+static void test_load_missing_file(void) {
+ char *err = NULL;
+ session_snapshot *snap =
+ session_load("/tmp/opencode/llsh_does_not_exist_12345.json", &err);
+ CHECK(snap == NULL, "missing file -> NULL");
+ CHECK(err && strstr(err, "reading") != NULL, "err names the path action");
+ free(err);
+}
+
+static void test_debug_dump(void) {
+ const char *path = "/tmp/opencode/llsh_debug_test.json";
+ unlink(path);
+
+ config c = base_config();
+ dotagents d = empty_dot();
+ mock_model *m = mk_model();
+ int ok_results[] = {SESS_STREAM_OK};
+ mock_set_stream(m, ok_results, 1, "hello there", 42);
+
+ session *s = session_new_with_model(&m->m, c, d);
+
+ /* No debug path set: the dump is a no-op and creates nothing. */
+ char *err = NULL;
+ CHECK(session_debug_dump(s, &err) == 0, "dump with no path is a no-op");
+ CHECK(err == NULL, "no-op sets no error");
+ CHECK(access(path, R_OK) != 0, "no-op creates no file");
+
+ /* Enabled: every turn refreshes the whole-history file. */
+ session_set_debug_path(s, xstrdup(path));
+ std::atomic<bool> stop(false);
+ int result = -1;
+ CHECK(session_run_turn(s, "hi", &stop, NULL, NULL, &err, &result) == 0,
+ "turn completes");
+ CHECK(result == SESS_TURN_COMPLETED, "result is Completed");
+ CHECK(access(path, R_OK) == 0, "debug file written after turn");
+
+ char *raw = NULL;
+ size_t len = 0;
+ char *rerr = NULL;
+ CHECK(read_file_to_buf(path, &raw, &len, &rerr) == 0, "file readable");
+ free(rerr);
+ bool parsed = true;
+ nlohmann::json o;
+ try {
+ o = nlohmann::json::parse(raw);
+ } catch (...) {
+ parsed = false;
+ }
+ CHECK(parsed, "file is valid JSON");
+ if (parsed) {
+ CHECK(o.is_array() && o.size() == 2, "file is the whole conversation");
+ CHECK(o[0].at("role") == "user", "first is user");
+ CHECK(o[0].at("parts")[0].at("text") == "hi", "user text preserved");
+ CHECK(o[1].at("role") == "assistant", "second is assistant");
+ CHECK(o[1].at("parts")[0].at("text") == "hello there",
+ "assistant text preserved");
+ }
+ free(raw);
+
+ /* A second turn grows the history and the file tracks it. */
+ mock_set_stream(m, ok_results, 1, "again", 99);
+ CHECK(session_run_turn(s, "hi again", &stop, NULL, NULL, &err, &result) ==
+ 0,
+ "second turn completes");
+ raw = NULL;
+ rerr = NULL;
+ CHECK(read_file_to_buf(path, &raw, &len, &rerr) == 0,
+ "file readable again");
+ free(rerr);
+ bool parsed2 = true;
+ nlohmann::json o2;
+ try {
+ o2 = nlohmann::json::parse(raw);
+ } catch (...) {
+ parsed2 = false;
+ }
+ CHECK(parsed2, "second dump is valid JSON");
+ if (parsed2) {
+ CHECK(o2.is_array() && o2.size() == 4, "history grew to 4 messages");
+ CHECK(o2[3].at("parts")[0].at("text") == "again",
+ "latest assistant text");
+ }
+ free(raw);
+
+ /* Disabling again turns the dump off. */
+ session_set_debug_path(s, NULL);
+ unlink(path);
+ err = NULL;
+ CHECK(session_debug_dump(s, &err) == 0, "dump after disable is a no-op");
+ CHECK(err == NULL, "no error after disable");
+ CHECK(access(path, R_OK) != 0, "disable creates no file");
+
+ session_free(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* main */
+/* ------------------------------------------------------------------ */
+
+int main(void) {
+ test_is_transient_status();
+ test_backoff_secs();
+ test_human_secs();
+ test_classify_retry();
+ test_context_overflow_detection();
+ test_assistant_emit();
+ test_tool_result_emit();
+
+ test_active_preset_from();
+ test_base_preamble_for();
+ test_preamble_for_profile();
+ test_apply_overrides();
+ test_effective_for_profile();
+
+ test_stop_before_start();
+ test_completed_turn_updates_history();
+ test_retry_then_success();
+ test_gives_up_after_retries();
+ test_no_retry_on_permanent();
+ test_retry_disabled();
+ test_overflow_recover_by_compacting();
+ test_overflow_fallback_clear_still_recovers();
+ test_overflow_retries_only_once();
+ test_overflow_no_recovery_without_limit();
+ test_overflow_no_stale_prefix_no_recovery();
+ test_records_usage();
+
+ test_set_profile();
+ test_set_model_preset();
+ test_current_preamble_switches_with_profile();
+ test_profile_cache_invalidates();
+
+ test_compact_disabled_at_zero_limit();
+ test_compact_below_thresholds();
+ test_compact_clears_stale_tools();
+ test_compact_summarizes_at_threshold();
+ test_forced_compact_reinjects_files();
+ test_forced_compact_fallback_clear();
+ test_forced_compact_noop_no_stale();
+ test_undo_last_turn();
+
+ test_goal_met();
+ test_goal_not_met();
+ test_goal_no_evaluator();
+ test_goal_evaluator_error();
+
+ test_save_resume_roundtrip();
+ test_resume_falls_back_to_main();
+ test_load_rejects_bad_version();
+ test_load_missing_file();
+
+ test_debug_dump();
+
+ printf("session: %d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/tests/test_shell.cpp b/tests/test_shell.cpp
new file mode 100644
index 0000000..bd10642
--- /dev/null
+++ b/tests/test_shell.cpp
@@ -0,0 +1,131 @@
+#include "shell.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+/* Spawn a shell, confirm it is a live process, then reap and free it. */
+static void test_spawn_and_reap(void)
+{
+ interactive_shell *s = shell_spawn();
+ CHECK(s != NULL, "shell_spawn returns a shell");
+ if (!s)
+ return;
+ CHECK(s->pid > 0, "shell has a live pid");
+ CHECK(s->master >= 0, "shell has a master fd");
+
+ /* Let it settle, then confirm it is still alive (not yet reaped). */
+ usleep(100 * 1000);
+ CHECK(s->pid > 0, "shell still alive before reap");
+
+ /* Free kills it and reaps; afterwards the pid is 0. */
+ shell_free(s);
+}
+
+/* Writing to a freed/closed shell must not crash. */
+static void test_write_after_free_is_safe(void)
+{
+ interactive_shell *s = shell_spawn();
+ CHECK(s != NULL, "shell_spawn");
+ if (!s)
+ return;
+ shell_free(s);
+ /* shell_write on a NULL shell is guarded by the caller; on a freed one we
+ * cannot call it (use-after-free), so just confirm free was clean. */
+}
+
+/* A shell that `cd`s and then exits must leave its final directory recorded
+ * (regression: the cwd was read after waitpid, when /proc/<pid>/cwd is gone,
+ * so `shell_final_cwd` returned NULL and llsh stayed in the old directory). */
+static void test_final_cwd_after_exit(void)
+{
+ setenv("SHELL", "/bin/bash", 1);
+ interactive_shell *s = shell_spawn();
+ CHECK(s != NULL, "shell_spawn");
+ if (!s)
+ return;
+ /* The sleep holds the shell alive after the cd, so the cwd poll (which
+ * only sees a live process) is guaranteed to observe /tmp. */
+ const char *cmd = "cd /tmp\nsleep 0.3\nexit\n";
+ for (size_t i = 0; i < strlen(cmd); i++)
+ CHECK(shell_write(s, cmd + i, 1) == 0, "write to shell");
+ bool reaped = false;
+ for (int i = 0; i < 200 && !reaped; i++) {
+ if (shell_reap_if_dead(s))
+ reaped = true;
+ else
+ usleep(25 * 1000);
+ }
+ CHECK(reaped, "shell reaped after exit");
+ char *cwd = shell_final_cwd(s);
+ CHECK(cwd != NULL, "final cwd is known");
+ if (cwd) {
+ CHECK(xstreq(cwd, "/tmp"), "final cwd is the shell's last directory");
+ free(cwd);
+ }
+ shell_free(s);
+}
+
+/* Output the shell prints is captured (sanitized, terminal escapes stripped)
+ * alongside streaming to stdout, so llsh can record a shell-mode visit
+ * (commands and output) in the conversation context. `shell_captured`
+ * returns the text and resets the buffer. */
+static void test_output_captured(void)
+{
+ setenv("SHELL", "/bin/bash", 1);
+ interactive_shell *s = shell_spawn();
+ CHECK(s != NULL, "shell_spawn");
+ if (!s)
+ return;
+ /* `echo OUTMARKER-$((1+1))` prints `OUTMARKER-2`, which only appears in
+ * the command's *output* (the input echo shows the unexpanded form), so a
+ * match proves the output was captured, not just the typed command. */
+ const char *cmd = "echo OUTMARKER-$((1+1))\n";
+ for (size_t i = 0; i < strlen(cmd); i++)
+ CHECK(shell_write(s, cmd + i, 1) == 0, "write to shell");
+ char *cap = NULL;
+ for (int i = 0; i < 200 && !cap; i++) {
+ usleep(50 * 1000);
+ char *c = shell_captured(s);
+ if (strstr(c, "OUTMARKER-2"))
+ cap = c; /* keep it */
+ else
+ free(c);
+ }
+ CHECK(cap != NULL, "captured output contains the command's output");
+ if (cap) {
+ /* The sanitized text must have no terminal escapes left. */
+ CHECK(strchr(cap, '\x1B') == NULL, "captured output has no ESC");
+ fprintf(stderr, "\n=== CAPTURED (%zu bytes) ===\n%s\n=== END ===\n",
+ strlen(cap), cap);
+ }
+ free(cap);
+ shell_free(s);
+}
+
+int main(int argc, char **argv)
+{
+ if (argc > 1 && xstreq(argv[1], "dump")) {
+ test_output_captured();
+ return 0;
+ }
+ test_spawn_and_reap();
+ test_write_after_free_is_safe();
+ test_final_cwd_after_exit();
+ test_output_captured();
+ printf("shell: %d checks, %d failures\n", checks, failures);
+ return failures == 0 ? 0 : 1;
+}
diff --git a/tests/test_tools.cpp b/tests/test_tools.cpp
new file mode 100644
index 0000000..3a92e05
--- /dev/null
+++ b/tests/test_tools.cpp
@@ -0,0 +1,1174 @@
+#include "tools.h"
+
+#include <cjson/cJSON.h>
+#include <ctype.h>
+#include <dirent.h>
+#include <math.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "limits.h"
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+#define CHECK_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (!hay || !strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \
+ __FILE__, __LINE__, (msg), hay ? hay : "(null)", \
+ (needle)); \
+ } \
+ } while (0)
+
+#define CHECK_NOT_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (hay && strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, must not contain `%s`)\n", \
+ __FILE__, __LINE__, (msg), hay, (needle)); \
+ } \
+ } while (0)
+
+static char *tmpdir(const char *tag) {
+ char path[256];
+ snprintf(path, sizeof path, "/tmp/llshc-tools-%s-%d", tag, (int)getpid());
+ char cmd[320];
+ snprintf(cmd, sizeof cmd, "rm -rf '%s'", path);
+ if (system(cmd) != 0) {}
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s'", path);
+ if (system(cmd) != 0)
+ exit(1);
+ return xstrdup(path);
+}
+
+static void wfile(const char *dir, const char *rel, const char *content) {
+ char path[512];
+ snprintf(path, sizeof path, "%s/%s", dir, rel);
+ char *slash = strrchr(path, '/');
+ if (slash) {
+ char dirpart[512];
+ size_t n = (size_t)(slash - path);
+ memcpy(dirpart, path, n);
+ dirpart[n] = '\0';
+ char cmd[600];
+ snprintf(cmd, sizeof cmd, "mkdir -p '%s'", dirpart);
+ if (system(cmd) != 0)
+ exit(1);
+ }
+ FILE *f = fopen(path, "w");
+ if (!f)
+ exit(1);
+ fputs(content, f);
+ fclose(f);
+}
+
+static tool_limits mklimits2(const char **list, size_t n, bool to_deny) {
+ tool_limits l;
+ tool_limits_defaults(&l);
+ for (size_t i = 0; i < n; i++)
+ sl_push_copy(to_deny ? &l.shell_denylist : &l.shell_allowlist,
+ list[i]);
+ return l;
+}
+
+static void test_read_write_roundtrip(void) {
+ char *dir = tmpdir("rw");
+ char *p = xstrfmt("%s/nested/roundtrip.txt", dir);
+ char *err = NULL;
+ char *r = tool_write_file(p, "hi there", &err);
+ CHECK(r != NULL, "write_file");
+ if (!r && err)
+ fprintf(stderr, " write_file err: %s\n", err);
+ free(r);
+ char *content = tool_read_file(p, 0, 0, &err);
+ CHECK(content && strcmp(content, "hi there") == 0, "read_file content");
+ if (!content && err)
+ fprintf(stderr, " read_file err: %s\n", err);
+ free(content);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(p);
+ free(dir);
+}
+
+static void test_read_file_missing_errors(void) {
+ char *dir = tmpdir("missing");
+ char *p = xstrfmt("%s/nope.txt", dir);
+ char *err = NULL;
+ char *r = tool_read_file(p, 0, 0, &err);
+ CHECK(r == NULL, "missing file should fail");
+ CHECK(err && strstr(err, "nope.txt") != NULL, "error names the path");
+ free(err);
+ free(p);
+ free(dir);
+}
+
+static void test_read_file_empty_path_errors(void) {
+ char *err = NULL;
+ CHECK(tool_read_file("", 0, 0, &err) == NULL, "empty path should fail");
+ CHECK(err && strstr(err, "path") != NULL, "error names the missing path");
+ free(err);
+ err = NULL;
+ CHECK(tool_read_file(NULL, 0, 0, &err) == NULL, "NULL path should fail");
+ CHECK(err && strstr(err, "path") != NULL, "NULL path error names it");
+ free(err);
+}
+
+static void test_read_file_too_large(void) {
+ char *dir = tmpdir("large");
+ char *p = xstrfmt("%s/big.bin", dir);
+ /* 3 MiB of zeros: above the 2 MiB default max_file_bytes */
+ FILE *f = fopen(p, "wb");
+ if (f) {
+ char chunk[4096];
+ memset(chunk, 0, sizeof chunk);
+ for (int i = 0; i < 768; i++)
+ fwrite(chunk, 1, sizeof chunk, f);
+ fclose(f);
+ }
+ char *err = NULL;
+ char *r = tool_read_file(p, 0, 0, &err);
+ CHECK(r == NULL, "oversized file should fail");
+ CHECK(err && strstr(err, "too large to read") != NULL,
+ "error says too large");
+ free(err);
+ free(p);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_read_file_binary_rejected(void) {
+ char *dir = tmpdir("bin");
+ char *p = xstrfmt("%s/blob.bin", dir);
+ FILE *f = fopen(p, "wb");
+ if (f) {
+ unsigned char b[] = {0xFF, 0xFE, 0x01};
+ fwrite(b, 1, sizeof b, f);
+ fclose(f);
+ }
+ char *err = NULL;
+ char *r = tool_read_file(p, 0, 0, &err);
+ CHECK(r == NULL, "binary file should fail");
+ CHECK(err && strstr(err, "valid UTF-8") != NULL,
+ "error mentions UTF-8");
+ free(err);
+ free(p);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_read_file_offset_limit(void) {
+ char *dir = tmpdir("offlimit");
+ char *p = xstrfmt("%s/lines.txt", dir);
+ wfile(dir, "lines.txt", "l1\nl2\nl3\nl4\nl5\n");
+ char *err = NULL;
+
+ /* offset=1, limit=2 -> first two lines + a truncation note */
+ char *r = tool_read_file(p, 1, 2, &err);
+ CHECK(r != NULL, "read with offset/limit succeeds");
+ CHECK_CONT(r, "l1\nl2", "first two lines present");
+ CHECK_CONT(r, "3 more lines", "notes how many lines remain");
+ CHECK_CONT(r, "offset=3", "notes the offset to continue from");
+ free(r);
+
+ /* offset=3, limit=2 -> lines 3-4, exactly one line remaining */
+ r = tool_read_file(p, 3, 2, &err);
+ CHECK(r != NULL &&
+ strcmp(r, "l3\nl4\n\n[... 1 more line; use offset=5 to continue "
+ "...]") == 0,
+ "middle slice + singular note, exact");
+ free(r);
+
+ /* limit beyond the end -> the rest of the file, no note */
+ r = tool_read_file(p, 2, 100, &err);
+ CHECK(r != NULL && strcmp(r, "l2\nl3\nl4\nl5\n") == 0,
+ "limit beyond end returns the rest with no note");
+ free(r);
+
+ /* the last single line, no note */
+ r = tool_read_file(p, 5, 1, &err);
+ CHECK(r != NULL && strcmp(r, "l5\n") == 0, "last line only, no note");
+ free(r);
+
+ /* offset past the end -> empty string */
+ r = tool_read_file(p, 6, 1, &err);
+ CHECK(r != NULL && strcmp(r, "") == 0, "offset past the end is empty");
+ free(r);
+
+ free(p);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_read_file_offset_after_unterminated_last_line(void) {
+ char *dir = tmpdir("unterminated");
+ char *path = xstrfmt("%s/one.txt", dir);
+ wfile(dir, "one.txt", "only line");
+ char *err = NULL;
+ char *out = tool_read_file(path, 2, 1, &err);
+ CHECK(out && strcmp(out, "") == 0,
+ "offset beyond unterminated final line returns empty");
+ free(out);
+ free(err);
+ free(path);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_read_file_rejects_embedded_nul(void) {
+ char *dir = tmpdir("nul");
+ char *path = xstrfmt("%s/nul.txt", dir);
+ FILE *f = fopen(path, "wb");
+ const char bytes[] = {'a', '\0', 'b'};
+ fwrite(bytes, 1, sizeof bytes, f);
+ fclose(f);
+ char *err = NULL;
+ char *out = tool_read_file(path, 0, 0, &err);
+ CHECK(out == NULL, "embedded NUL is rejected as non-text");
+ free(out);
+ free(err);
+ free(path);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_write_file_rejects_missing_content(void) {
+ char *err = NULL;
+ CHECK(tool_write_file("/tmp/llsh-must-not-be-created", NULL, &err) == NULL,
+ "missing content rejected");
+ CHECK(err && strstr(err, "content"), "missing content is explained");
+ free(err);
+}
+
+static void test_run_shell_success_output(void) {
+ char *err = NULL;
+ char *out = tool_run_shell("echo hello-llsh", &err);
+ CHECK(out != NULL, "run_shell");
+ CHECK_CONT(out, "hello-llsh", "stdout captured");
+ CHECK_NOT_CONT(out, "exit code 0", "no status note on success");
+ free(out);
+}
+
+static void test_run_shell_success_no_output(void) {
+ char *err = NULL;
+ char *out = tool_run_shell("true", &err);
+ CHECK(out && strcmp(out, "(no output)") == 0, "no-output marker");
+ if (!out && err)
+ fprintf(stderr, " err: %s\n", err);
+ free(out);
+}
+
+static void test_run_shell_failure_status(void) {
+ char *err = NULL;
+ char *out = tool_run_shell("exit 3", &err);
+ CHECK(out != NULL, "failing command still returns output");
+ CHECK_CONT(out, "exit code 3", "exit code reported");
+ free(out);
+}
+
+static void test_run_shell_stderr_section(void) {
+ char *err = NULL;
+ char *out = tool_run_shell("echo out-line; echo err-line 1>&2", &err);
+ CHECK(out != NULL, "run_shell with stderr");
+ CHECK_CONT(out, "out-line", "stdout part");
+ CHECK_CONT(out, "[stderr]\nerr-line", "stderr section");
+ free(out);
+}
+
+static void test_run_shell_signal(void) {
+ char *err = NULL;
+ char *out = tool_run_shell("kill -9 $$", &err);
+ CHECK(out != NULL, "signaled command returns output");
+ CHECK_CONT(out, "terminated by signal", "signal note");
+ free(out);
+}
+
+static void test_run_shell_denylist_blocks(void) {
+ tool_limits saved;
+ tool_limits_get(&saved);
+ const char *deny[] = {"curl"};
+ tool_limits l = mklimits2(deny, 1, true);
+ tool_limits_set(&l);
+ char *err = NULL;
+ char *out = tool_run_shell("curl example.com", &err);
+ CHECK(out == NULL, "denylisted command blocked");
+ CHECK(err && strstr(err, "denylist") != NULL, "denylist mentioned");
+ free(err);
+ tool_limits_free(&l);
+ tool_limits_set(&saved);
+ tool_limits_free(&saved);
+}
+
+static void test_run_shell_allowlist_requires_match(void) {
+ tool_limits saved;
+ tool_limits_get(&saved);
+ const char *allow[] = {"git"};
+ tool_limits l = mklimits2(allow, 1, false);
+ tool_limits_set(&l);
+ char *err = NULL;
+ char *out = tool_run_shell("git status", &err);
+ CHECK(out != NULL, "allowlisted command runs");
+ free(out);
+ err = NULL;
+ out = tool_run_shell("ls -la", &err);
+ CHECK(out == NULL, "non-allowlisted command blocked");
+ CHECK(err && strstr(err, "allowlist") != NULL, "allowlist mentioned");
+ free(err);
+ free(out);
+ tool_limits_free(&l);
+ tool_limits_set(&saved);
+ tool_limits_free(&saved);
+}
+
+static bool deny_hook(const char *cmd, void *ud) {
+ (void)cmd;
+ (void)ud;
+ return false;
+}
+
+static void test_run_shell_confirm_hook_aborts(void) {
+ tool_limits saved;
+ tool_limits_get(&saved);
+ tool_limits l;
+ tool_limits_defaults(&l);
+ l.confirm_destructive = true;
+ tool_limits_set(&l);
+ set_confirm_hook(deny_hook, NULL);
+ char *err = NULL;
+ char *out = tool_run_shell("rm -rf /tmp/llsh-test", &err);
+ CHECK(out == NULL, "declined destructive command aborts");
+ CHECK(err && strstr(err, "declined") != NULL, "declined mentioned");
+ free(err);
+ set_confirm_hook(NULL, NULL);
+ tool_limits_free(&l);
+ tool_limits_set(&saved);
+ tool_limits_free(&saved);
+}
+
+/* Number of live /proc entries whose cmdline contains `needle`. */
+static int leftover_cmdlines(const char *needle) {
+ DIR *d = opendir("/proc");
+ if (!d)
+ return 0;
+ int found = 0;
+ struct dirent *e;
+ while ((e = readdir(d)) != NULL) {
+ bool digits = e->d_name[0] != '\0';
+ for (const char *c = e->d_name; *c; c++)
+ if (!isdigit((unsigned char)*c)) {
+ digits = false;
+ break;
+ }
+ if (!digits)
+ continue;
+ char path[300];
+ snprintf(path, sizeof path, "/proc/%s/cmdline", e->d_name);
+ FILE *f = fopen(path, "rb");
+ if (!f)
+ continue;
+ char buf[8192];
+ size_t n = fread(buf, 1, sizeof buf - 1, f);
+ fclose(f);
+ buf[n] = '\0';
+ for (size_t i = 0; i <= n && !found; i++) {
+ if (i < n && buf[i] != '\0')
+ continue;
+ if (strstr(buf + i, needle))
+ found = 1;
+ i++;
+ }
+ }
+ closedir(d);
+ return found;
+}
+
+static void test_run_shell_timeout_kills_process_group(void) {
+ char *err = NULL;
+ char *out = tool_run_shell_timed("sleep 60 & echo spawned; wait", 1, &err);
+ CHECK(out == NULL, "long command times out");
+ CHECK(err && strstr(err, "timed out") != NULL, "timeout message");
+ free(err);
+ /* Give the kernel a moment to reap, then check nothing survived. */
+ struct timespec ts = {0, 300 * 1000 * 1000};
+ nanosleep(&ts, NULL);
+ CHECK(leftover_cmdlines("sleep 60") == 0,
+ "backgrounded sleep must die with its process group");
+}
+
+static void test_run_shell_large_output_does_not_hang(void) {
+ /* A child writing more than MAX_SHELL_OUTPUT_BYTES (1MB) used to block on
+ * a full pipe because draining stopped at the cap, forcing a full timeout.
+ * It must now complete promptly with its output capped. */
+ char *err = NULL;
+ char *out = tool_run_shell_timed("head -c 2000000 /dev/zero | tr '\\0' 'x'",
+ 5, &err);
+ CHECK(out != NULL, "large output completes (no spurious timeout)");
+ if (out) {
+ CHECK(strlen(out) <= 16000 + 256, "output is capped/truncated");
+ free(out);
+ }
+ free(err);
+}
+
+static void test_truncate_output(void) {
+ xbuf long_b;
+ xbuf_init(&long_b);
+ for (int i = 0; i < 20000; i++)
+ xbuf_push(&long_b, 'x');
+ char *long_s = xbuf_steal(&long_b);
+ char *t = tool_truncate_output(long_s);
+ CHECK_CONT(t, "truncated", "truncation note");
+ CHECK(strlen(t) < 20000, "output shortened");
+ free(t);
+ char *short_s = xstrdup("small");
+ char *t2 = tool_truncate_output(short_s);
+ CHECK(t2 && strcmp(t2, "small") == 0, "short text unchanged");
+ free(t2);
+ free(long_s);
+ free(short_s);
+}
+
+static void test_tool_list(void) {
+ const tool_entry *entries;
+ size_t n = tool_list_entries(&entries);
+ CHECK(n == 8, "eight tools registered");
+ const char *want[8] = {"read_file", "write_file", "run_shell",
+ "use_skill", "search_memories", "embed",
+ "llm", "compact"};
+ bool ok = true;
+ for (size_t i = 0; i < n; i++)
+ if (strcmp(entries[i].name, want[i]) != 0)
+ ok = false;
+ CHECK(ok, "tool names in order");
+}
+
+static void test_tool_params_schemas(void) {
+ for (size_t i = 0; i < tool_builtin_count(); i++) {
+ const char *name = tool_builtin_name(i);
+ cJSON *schema = tool_params_json(name);
+ CHECK(schema != NULL, "schema non-NULL");
+ const cJSON *stype = cJSON_GetObjectItem(schema, "type");
+ CHECK(cJSON_IsString(stype) && strcmp(stype->valuestring, "object") == 0,
+ "schema type is object");
+ const cJSON *props = cJSON_GetObjectItem(schema, "properties");
+ CHECK(cJSON_IsObject(props), "properties is an object");
+ if (!cJSON_IsObject(props)) {
+ cJSON_Delete(schema);
+ continue;
+ }
+ for (const cJSON *p = props->child; p; p = p->next) {
+ int duplicates = 0;
+ for (const cJSON *q = props->child; q; q = q->next)
+ if (p->string && q->string && strcmp(p->string, q->string) == 0)
+ duplicates++;
+ CHECK(duplicates == 1, "property key appears exactly once");
+ CHECK(cJSON_HasObjectItem(p, "type"), "property carries a type");
+ }
+ cJSON_Delete(schema);
+ }
+
+ cJSON *rf = tool_params_json("read_file");
+ const cJSON *path = cJSON_GetObjectItem(
+ cJSON_GetObjectItem(rf, "properties"), "path");
+ CHECK(cJSON_IsObject(path), "read_file has a path property");
+ const cJSON *ptype = path ? cJSON_GetObjectItem(path, "type") : NULL;
+ CHECK(cJSON_IsString(ptype) && strcmp(ptype->valuestring, "string") == 0,
+ "read_file path type is string");
+ const cJSON *pd = path ? cJSON_GetObjectItem(path, "description") : NULL;
+ CHECK(cJSON_IsString(pd) && pd->valuestring[0] != 0,
+ "read_file path keeps its description");
+ char *rfstr = cJSON_PrintUnformatted(rf);
+ CHECK_CONT(rfstr, "\"path\":{\"type\":\"string\",\"description\":",
+ "read_file schema serializes path property");
+ free(rfstr);
+ cJSON_Delete(rf);
+}
+
+static void test_tokenize(void) {
+ strlist t;
+ sl_init(&t);
+ tool_tokenize("Billing Notes (DB)", &t);
+ CHECK(t.len == 3, "three tokens");
+ CHECK(t.len == 3 && strcmp(t.items[0], "billing") == 0, "token 1");
+ CHECK(t.len == 3 && strcmp(t.items[1], "notes") == 0, "token 2");
+ CHECK(t.len == 3 && strcmp(t.items[2], "db") == 0, "token 3");
+ sl_free(&t);
+ strlist empty;
+ sl_init(&empty);
+ tool_tokenize(" ", &empty);
+ CHECK(empty.len == 0, "whitespace-only query has no tokens");
+ sl_free(&empty);
+}
+
+static void test_is_subsequence(void) {
+ CHECK(tool_is_subsequence("abc", "a1b2c3"), "scattered chars match");
+ CHECK(!tool_is_subsequence("abc", "acb"), "out-of-order rejected");
+ CHECK(tool_is_subsequence("bill", "billed"), "prefix subsequence");
+ CHECK(!tool_is_subsequence("billing", "billed"), "longer needle rejected");
+}
+
+static void test_field_match_strength(void) {
+ const char *field = "billing is monthly";
+ double word = tool_field_match_strength(field, "billing");
+ double sub = tool_field_match_strength(field, "billin");
+ double fuzzy = tool_field_match_strength(field, "billng");
+ double none = tool_field_match_strength(field, "zzz");
+ CHECK(word > sub, "word beats substring");
+ CHECK(sub > fuzzy, "substring beats fuzzy");
+ CHECK(fuzzy > none, "fuzzy beats no match");
+ CHECK(none == 0.0, "no match is zero");
+ CHECK(tool_field_match_strength("abc", "ac") == 0.0,
+ "2-char terms get no fuzzy credit");
+ CHECK(tool_field_match_strength("billing", "billing") == 1.0,
+ "exact match is 1.0");
+ CHECK(tool_field_match_strength("", "x") == 0.0, "empty field is zero");
+}
+
+static memory mk_mem(const char *id, const char *importance, const char *title,
+ const char **tags, size_t ntags, const char *summary,
+ const char *body) {
+ memory m;
+ m.id = xstrdup(id);
+ m.title = xstrdup(title);
+ m.importance = xstrdup(importance);
+ m.tags = NULL;
+ m.tag_count = 0;
+ for (size_t i = 0; i < ntags; i++)
+ m.tags = (char* * ) realloc(m.tags, (m.tag_count + 1) * sizeof(char *)),
+ m.tags[m.tag_count++] = xstrdup(tags[i]);
+ m.summary = xstrdup(summary);
+ m.body = xstrdup(body);
+ m.layer = LAYER_WORKSPACE;
+ return m;
+}
+
+static void free_mem(memory *m) {
+ free(m->id);
+ free(m->title);
+ free(m->importance);
+ for (size_t i = 0; i < m->tag_count; i++)
+ free(m->tags[i]);
+ free(m->tags);
+ free(m->summary);
+ free(m->body);
+}
+
+static void test_memory_relevance(void) {
+ strlist q_billing;
+ sl_init(&q_billing);
+ tool_tokenize("billing", &q_billing);
+ memory in_title =
+ mk_mem("a", "low", "Billing", NULL, 0, "", "");
+ memory in_body = mk_mem("b", "low", "Other", NULL, 0, "",
+ "billing monthly");
+ CHECK(tool_memory_relevance(&in_title, &q_billing) >
+ tool_memory_relevance(&in_body, &q_billing),
+ "title hit outranks body-only hit");
+ strlist q_two;
+ sl_init(&q_two);
+ tool_tokenize("billing note", &q_two);
+ memory covers_both = mk_mem("c", "low", "Billing Note", NULL, 0, "", "");
+ memory covers_one = mk_mem("d", "low", "Billing", NULL, 0, "", "");
+ CHECK(tool_memory_relevance(&covers_both, &q_two) >
+ tool_memory_relevance(&covers_one, &q_two),
+ "covering both terms outranks covering one");
+ memory unrelated = mk_mem("e", "low", "Unrelated", NULL, 0, "", "");
+ CHECK(tool_memory_relevance(&unrelated, &q_billing) == 0.0,
+ "no match scores zero");
+ free_mem(&in_title);
+ free_mem(&in_body);
+ free_mem(&covers_both);
+ free_mem(&covers_one);
+ free_mem(&unrelated);
+ sl_free(&q_billing);
+ sl_free(&q_two);
+}
+
+static void test_search_memories_ranks_relevance(void) {
+ char *home = getcwd(NULL, 0);
+ char *dir = tmpdir("memrank");
+ wfile(dir, ".agents/memories/important.md",
+ "---\nid: important\ntitle: Random\nimportance: high\n---\n"
+ "We mention billing here.");
+ wfile(dir, ".agents/memories/relevant.md",
+ "---\nid: relevant\ntitle: Billing\nimportance: low\n---\n"
+ "Details.");
+ if (chdir(dir) != 0)
+ exit(1);
+ dotagents dot;
+ dotagents_load(&dot);
+ char *err = NULL;
+ char *out = tool_search_memories(&dot, "billing", &err);
+ CHECK(out != NULL, "search_memories");
+ if (!out && err)
+ fprintf(stderr, " err: %s\n", err);
+ char *p_relevant = out ? strstr(out, "## Billing") : NULL;
+ char *p_important = out ? strstr(out, "## Random") : NULL;
+ CHECK(p_relevant && p_important && p_relevant < p_important,
+ "relevance outranks importance");
+ free(out);
+ dotagents_free(&dot);
+ if (chdir(home) != 0)
+ exit(1);
+ free(home);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_search_memories_empty_query(void) {
+ char *err = NULL;
+ char *out = tool_search_memories(NULL, " ", &err);
+ CHECK(out == NULL, "blank query fails");
+ CHECK(err && strstr(err, "query must not be empty") != NULL,
+ "error message");
+ free(err);
+}
+
+static void test_use_skill_and_search_memories_workspace(void) {
+ char *home = getcwd(NULL, 0);
+ char *dir = tmpdir("ws");
+ wfile(dir, ".agents/skills/demo/skill.md",
+ "---\nid: demo\nname: Demo Skill\ndescription: a demo\n---\n"
+ "Step one. Step two.");
+ wfile(dir, ".agents/memories/note.md",
+ "---\nid: note\ntitle: Billing Note\nimportance: high\n"
+ "tags: billing, db\n---\nWe bill monthly.");
+ if (chdir(dir) != 0)
+ exit(1);
+ dotagents dot;
+ dotagents_load(&dot);
+ char *err = NULL;
+ char *out = tool_use_skill(&dot, "demo", &err);
+ CHECK(out != NULL, "use_skill");
+ if (!out && err)
+ fprintf(stderr, " use_skill err: %s\n", err);
+ CHECK_CONT(out, "Demo Skill", "skill name");
+ CHECK_CONT(out, "Step one. Step two.", "skill body");
+ free(out);
+ err = NULL;
+ out = tool_use_skill(&dot, "nope", &err);
+ CHECK(out == NULL, "unknown skill fails");
+ CHECK(err && strstr(err, "no skill") != NULL, "error names available");
+ free(err);
+ err = NULL;
+ out = tool_search_memories(&dot, "billing", &err);
+ CHECK(out != NULL, "search_memories hit");
+ CHECK_CONT(out, "Billing Note", "memory title");
+ CHECK_CONT(out, "importance: high", "importance line");
+ CHECK_CONT(out, "tags: billing, db", "tags line");
+ free(out);
+ err = NULL;
+ out = tool_search_memories(&dot, "zzz-qqq-xxx", &err);
+ CHECK(out != NULL, "no-match search returns a message");
+ CHECK_CONT(out, "no memories matching", "no-match message");
+ free(out);
+ dotagents_free(&dot);
+ if (chdir(home) != 0)
+ exit(1);
+ free(home);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+/* Tools must serve the session's loaded `.agents` snapshot, not re-read
+ * the disk (AUDIT: no live mid-session reload). */
+static void test_tools_read_snapshot_not_disk(void) {
+ char *home = getcwd(NULL, 0);
+ char *dir = tmpdir("snap");
+ wfile(dir, ".agents/skills/demo/skill.md",
+ "---\nid: demo\nname: Demo Skill\ndescription: a demo\n---\n"
+ "ORIGINAL BODY.");
+ wfile(dir, ".agents/memories/note.md",
+ "---\nid: note\ntitle: Billing Note\nimportance: high\n"
+ "tags: billing\n---\nORIGINAL MEMORY.");
+ if (chdir(dir) != 0)
+ exit(1);
+ dotagents dot;
+ dotagents_load(&dot);
+
+ /* Mutate the on-disk state after the snapshot was taken. */
+ wfile(dir, ".agents/skills/demo/skill.md",
+ "---\nid: demo\nname: Demo Skill\ndescription: a demo\n---\n"
+ "REPLACED BODY.");
+ wfile(dir, ".agents/memories/note.md", "entirely new memory");
+ wfile(dir, ".agents/skills/fresh/skill.md",
+ "---\nid: fresh\nname: Fresh\ndescription: added later\n---\n"
+ "brand new skill.");
+
+ char *err = NULL;
+ char *out = tool_use_skill(&dot, "demo", &err);
+ CHECK(out != NULL, "skill served from snapshot");
+ CHECK_CONT(out, "ORIGINAL BODY.", "pre-mutation body intact");
+ CHECK_NOT_CONT(out, "REPLACED BODY", "disk change not visible");
+ free(out);
+ err = NULL;
+ out = tool_search_memories(&dot, "billing", &err);
+ CHECK(out != NULL, "memory served from snapshot");
+ CHECK_CONT(out, "ORIGINAL MEMORY.", "pre-mutation memory body");
+ CHECK_NOT_CONT(out, "entirely new memory", "new disk file not visible");
+ free(out);
+ err = NULL;
+ out = tool_use_skill(&dot, "fresh", &err);
+ CHECK(out == NULL, "skill added after snapshot is unknown");
+ CHECK(err != NULL, "unknown-skill error set");
+ free(out);
+ free(err);
+
+ dotagents_free(&dot);
+ if (chdir(home) != 0)
+ exit(1);
+ free(home);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_write_file_creates_parents(void) {
+ char *dir = tmpdir("wpar");
+ char *p = xstrfmt("%s/deep/deeper/file.txt", dir);
+ char *err = NULL;
+ char *out = tool_write_file(p, "nested", &err);
+ CHECK(out != NULL, "write_file with new parents");
+ CHECK_CONT(out, "wrote 6 bytes to", "byte count reported");
+ free(out);
+ char *back = tool_read_file(p, 0, 0, &err);
+ CHECK(back && strcmp(back, "nested") == 0, "content roundtrip");
+ free(back);
+ free(p);
+ char *rm = xstrfmt("rm -rf '%s'", dir);
+ system(rm);
+ free(rm);
+ free(dir);
+}
+
+static void test_display_vtable(void) {
+ const tool_display *op;
+
+ op = tool_display_for("run_shell");
+ CHECK(op != NULL, "run_shell has a vtable entry");
+ CHECK(op->render_call != NULL, "run_shell has a custom renderer");
+ CHECK(op->dim_result, "run_shell results render muted");
+
+ char *line = op->render_call("{\"command\":\"ls -la src\"}");
+ CHECK(line != NULL, "renderer returns a line");
+ CHECK_CONT(line, "$ ", "renders a shell prompt line");
+ CHECK_CONT(line, "ls -la src", "shell line carries the command");
+ CHECK_NOT_CONT(line, "command", "raw JSON key does not leak");
+ CHECK(line && line[0] == '\n' && line[strlen(line) - 1] == '\n',
+ "line framed with newlines");
+ free(line);
+
+ CHECK(tool_display_for("run_shell")->render_call(NULL) == NULL,
+ "NULL args -> generic fallback");
+ CHECK(tool_display_for("run_shell")->render_call("{}") == NULL,
+ "missing command -> generic fallback");
+ CHECK(tool_display_for("run_shell")->render_call("nope") == NULL,
+ "bad json -> generic fallback");
+
+ op = tool_display_for("read_file");
+ CHECK(op && op->render_call == NULL && op->dim_result,
+ "read_file: generic display, muted result");
+ op = tool_display_for("write_file");
+ CHECK(op && op->render_call == NULL && !op->dim_result,
+ "write_file: generic display, normal result");
+ op = tool_display_for("use_skill");
+ CHECK(op && op->render_call == NULL && !op->dim_result,
+ "use_skill: generic display, normal result");
+ op = tool_display_for("search_memories");
+ CHECK(op && op->render_call == NULL && !op->dim_result,
+ "search_memories: generic display, normal result");
+
+ CHECK(tool_display_for("frobnicate") == NULL, "unknown tool -> NULL");
+ CHECK(tool_display_for("mcp__srv__tool") == NULL, "mcp tool -> NULL");
+ CHECK(tool_display_for(NULL) == NULL, "NULL name -> NULL");
+}
+
+/* ------------------------------------------------------------------ */
+/* embed tool */
+/* ------------------------------------------------------------------ */
+
+static dotagents embed_dot(const char *model, const char *key,
+ const char *url) {
+ dotagents d;
+ memset(&d, 0, sizeof d);
+ fm_sl_init(&d.warnings);
+ d.models.default_name = xstrdup("emb");
+ d.models.presets = (model_preset *) xmalloc(sizeof *d.models.presets);
+ memset(d.models.presets, 0, sizeof *d.models.presets);
+ d.models.presets[0].name = xstrdup("emb");
+ d.models.presets[0].model = model ? xstrdup(model) : NULL;
+ d.models.presets[0].api_key = key ? xstrdup(key) : NULL;
+ d.models.presets[0].base_url = url ? xstrdup(url) : NULL;
+ d.models.preset_count = 1;
+ d.has_models = true;
+ return d;
+}
+
+static config embed_cfg(const char *model, const char *key, const char *url) {
+ config c;
+ memset(&c, 0, sizeof c);
+ sl_init(&c.shell_allowlist);
+ sl_init(&c.shell_denylist);
+ if (model)
+ c.model = xstrdup(model);
+ if (key)
+ c.api_key = xstrdup(key);
+ if (url)
+ c.base_url = xstrdup(url);
+ return c;
+}
+
+static void free_embed_cfg(config *c) {
+ free(c->model);
+ free(c->api_key);
+ free(c->base_url);
+ sl_free(&c->shell_allowlist);
+ sl_free(&c->shell_denylist);
+}
+
+static void test_embed_resolve_profile_values(void) {
+ config c = embed_cfg(NULL, NULL, NULL);
+ dotagents d = embed_dot("emb-model", "emb-key", "https://emb.example/v1");
+ embed_target t;
+ char *err = NULL;
+ CHECK(tool_embed_resolve(&c, &d, &t, &err) == 0, "resolve ok");
+ CHECK(t.model && strcmp(t.model, "emb-model") == 0, "preset model");
+ CHECK(t.api_key && strcmp(t.api_key, "emb-key") == 0, "preset api_key");
+ CHECK(t.base_url && strcmp(t.base_url, "https://emb.example/v1") == 0,
+ "preset base_url");
+ embed_target_free(&t);
+ dotagents_free(&d);
+ free_embed_cfg(&c);
+}
+
+static void test_embed_resolve_falls_back_to_top_level(void) {
+ config c = embed_cfg("top-model", "top-key", "https://top.example/v1");
+ dotagents d = embed_dot(NULL, NULL, NULL); /* preset with empty fields */
+ embed_target t;
+ char *err = NULL;
+ CHECK(tool_embed_resolve(&c, &d, &t, &err) == 0, "resolve ok");
+ CHECK(t.model && strcmp(t.model, "top-model") == 0, "inherits model");
+ CHECK(t.api_key && strcmp(t.api_key, "top-key") == 0, "inherits api_key");
+ CHECK(t.base_url && strcmp(t.base_url, "https://top.example/v1") == 0,
+ "inherits base_url");
+ embed_target_free(&t);
+ dotagents_free(&d);
+ free_embed_cfg(&c);
+}
+
+static void test_embed_resolve_errors(void) {
+ config c;
+ memset(&c, 0, sizeof c);
+ sl_init(&c.shell_allowlist);
+ sl_init(&c.shell_denylist);
+ embed_target t;
+ char *err = NULL;
+
+ CHECK(tool_embed_resolve(&c, NULL, &t, &err) == -1 && err,
+ "no models.json -> error");
+ CHECK(err && strstr(err, "not configured"), "not-configured message");
+ free(err);
+ err = NULL;
+
+ dotagents d = embed_dot("emb-model", "emb-key", "https://emb.example/v1");
+ free(d.models.default_name);
+ d.models.default_name = xstrdup("ghost");
+ CHECK(tool_embed_resolve(&c, &d, &t, &err) == -1 && err,
+ "unknown preset -> error");
+ CHECK(err && strstr(err, "ghost"), "unknown-preset message");
+ free(err);
+ err = NULL;
+ dotagents_free(&d);
+
+ config c2 = embed_cfg(NULL, NULL, NULL);
+ dotagents d2 = embed_dot("emb-model", NULL, "https://emb.example/v1");
+ err = NULL;
+ CHECK(tool_embed_resolve(&c2, &d2, &t, &err) == -1 && err,
+ "preset missing api_key -> error");
+ CHECK(err && strstr(err, "api_key"), "missing-key message");
+ free(err);
+ dotagents_free(&d2);
+ free_embed_cfg(&c2);
+
+ sl_free(&c.shell_allowlist);
+ sl_free(&c.shell_denylist);
+}
+
+static void test_embed_request_body(void) {
+ char *err = NULL;
+ char *req = tool_embed_request("emb-model", "hello \"world\"\n", &err);
+ CHECK(req && !err, "request built");
+ CHECK_CONT(req, "\"model\":\"emb-model\"", "model in body");
+ CHECK_CONT(req, "\"input\":\"hello \\\"world\\\"\\n\"", "input escaped");
+ free(req);
+ free(err);
+ err = NULL;
+ CHECK(tool_embed_request("", "x", &err) == NULL, "empty model rejected");
+ free(err);
+ err = NULL;
+ CHECK(tool_embed_request("m", "", &err) == NULL, "empty text rejected");
+ free(err);
+}
+
+static void test_embed_url(void) {
+ char *u = tool_embed_url("https://avois.org/v1/");
+ CHECK(u && strcmp(u, "https://avois.org/v1/embeddings") == 0,
+ "trailing slash trimmed");
+ free(u);
+ u = tool_embed_url("http://localhost:11434");
+ CHECK(u && strcmp(u, "http://localhost:11434/embeddings") == 0,
+ "plain base");
+ free(u);
+ u = tool_embed_url("");
+ CHECK(u && strcmp(u, "https://api.openai.com/v1/embeddings") == 0,
+ "default base");
+ free(u);
+}
+
+static void test_embed_parse(void) {
+ char *vec = NULL;
+ char *err = NULL;
+ const char *body =
+ "{\"object\":\"list\",\"data\":[{\"object\":\"embedding\","
+ "\"embedding\":[0.1,-0.2,0.3],\"index\":0}],\"model\":\"emb\"}";
+ CHECK(tool_embed_parse(body, &vec, &err) == 0, "parse ok");
+ CHECK(vec, "vector non-NULL");
+ /* Compare values, not serialization: float formatting is not stable
+ * across FP environments (valgrind shifts last ulps). */
+ cJSON *v = vec ? cJSON_Parse(vec) : NULL;
+ CHECK(v && cJSON_IsArray(v) && cJSON_GetArraySize(v) == 3,
+ "vector has 3 elements");
+ if (v && cJSON_IsArray(v) && cJSON_GetArraySize(v) == 3) {
+ CHECK(fabs(cJSON_GetArrayItem(v, 0)->valuedouble - 0.1) < 1e-9,
+ "v[0] ~= 0.1");
+ CHECK(fabs(cJSON_GetArrayItem(v, 1)->valuedouble - (-0.2)) < 1e-9,
+ "v[1] ~= -0.2");
+ CHECK(fabs(cJSON_GetArrayItem(v, 2)->valuedouble - 0.3) < 1e-9,
+ "v[2] ~= 0.3");
+ cJSON_Delete(v);
+ }
+ CHECK(!err, "no error");
+ free(vec);
+ vec = NULL;
+
+ CHECK(tool_embed_parse("{\"data\":[]}", &vec, &err) == -1,
+ "empty data -> error");
+ CHECK(err && strstr(err, "data[0].embedding"), "missing-array message");
+ free(err);
+ err = NULL;
+ CHECK(tool_embed_parse("{\"data\":[{\"embedding\":[1,\"x\"]}]}", &vec,
+ &err) == -1,
+ "non-numeric element -> error");
+ free(err);
+ err = NULL;
+ CHECK(tool_embed_parse("not json", &vec, &err) == -1, "bad JSON -> error");
+ free(err);
+ err = NULL;
+ CHECK(tool_embed_parse("", &vec, &err) == -1, "empty body -> error");
+ free(err);
+}
+
+static void test_embed_tool_unset(void) {
+ config c;
+ memset(&c, 0, sizeof c);
+ sl_init(&c.shell_allowlist);
+ sl_init(&c.shell_denylist);
+ char *err = NULL;
+ CHECK(tool_embed("text", &c, NULL, &err) == NULL, "unset -> NULL");
+ CHECK(err && strstr(err, "not configured"), "unset message");
+ free(err);
+ sl_free(&c.shell_allowlist);
+ sl_free(&c.shell_denylist);
+}
+
+static config mkllmcfg(void) {
+ config c;
+ memset(&c, 0, sizeof c);
+ sl_init(&c.shell_allowlist);
+ sl_init(&c.shell_denylist);
+ c.provider = PROV_OPENAI;
+ c.model = xstrdup("test-model");
+ c.base_url = xstrdup("http://127.0.0.1:9/v1");
+ c.api_key = xstrdup("k");
+ return c;
+}
+
+static void freellmcfg(config *c) {
+ sl_free(&c->shell_allowlist);
+ sl_free(&c->shell_denylist);
+ free((void *)c->model);
+ free((void *)c->base_url);
+ free((void *)c->api_key);
+}
+
+/* ------------------------------------------------------------------ */
+/* llm tool */
+/* ------------------------------------------------------------------ */
+
+static void test_llm_tool_schemas(void) {
+ const char *names[] = {"llm", "compact"};
+ for (size_t i = 0; i < sizeof names / sizeof names[0]; i++) {
+ const char *name = names[i];
+ cJSON *schema = tool_params_json(name);
+ CHECK(schema != NULL, "schema non-NULL");
+ if (!schema)
+ continue;
+ const cJSON *stype = cJSON_GetObjectItem(schema, "type");
+ CHECK(cJSON_IsString(stype) && strcmp(stype->valuestring, "object") == 0,
+ "schema type is object");
+ CHECK(cJSON_IsObject(cJSON_GetObjectItem(schema, "properties")),
+ "properties is an object");
+ CHECK(cJSON_IsArray(cJSON_GetObjectItem(schema, "required")),
+ "required is an array");
+ cJSON_Delete(schema);
+ }
+
+ /* llm: required `prompt`, and every documented parameter present. */
+ cJSON *llm = tool_params_json("llm");
+ const cJSON *req = cJSON_GetObjectItem(llm, "required");
+ int req_has_prompt = 0;
+ if (cJSON_IsArray(req)) {
+ for (const cJSON *r = req->child; r; r = r->next)
+ if (cJSON_IsString(r) && strcmp(r->valuestring, "prompt") == 0)
+ req_has_prompt = 1;
+ }
+ CHECK(req_has_prompt, "llm requires prompt");
+ const char *props[] = {"prompt", "model", "temperature",
+ "max_tokens", "reasoning_effort", "thinking_budget",
+ "response_schema", "embed"};
+ const cJSON *llmprops = cJSON_GetObjectItem(llm, "properties");
+ for (size_t i = 0; i < sizeof props / sizeof props[0]; i++)
+ CHECK(cJSON_HasObjectItem(llmprops, props[i]), props[i]);
+ /* reasoning_effort is an enum of the three allowed values. */
+ const cJSON *re = cJSON_GetObjectItem(llmprops, "reasoning_effort");
+ const cJSON *reenum = cJSON_GetObjectItem(re, "enum");
+ CHECK(cJSON_IsArray(reenum) && cJSON_GetArraySize(reenum) == 3,
+ "reasoning_effort enum has 3 values");
+ cJSON_Delete(llm);
+
+ /* compact: takes no parameters. */
+ cJSON *cmp = tool_params_json("compact");
+ const cJSON *cmpreq = cJSON_GetObjectItem(cmp, "required");
+ CHECK(cJSON_IsArray(cmpreq) && cJSON_GetArraySize(cmpreq) == 0,
+ "compact required is empty");
+ const cJSON *cmpprops = cJSON_GetObjectItem(cmp, "properties");
+ CHECK(cJSON_IsObject(cmpprops) && cmpprops->child == NULL,
+ "compact properties is empty");
+ cJSON_Delete(cmp);
+}
+
+static void test_llm_tool_requires_prompt(void) {
+ config c = mkllmcfg();
+ llm_tool_call call;
+ memset(&call, 0, sizeof call);
+ char *err = NULL;
+ CHECK(tool_llm(&call, &c, NULL, &err) == NULL, "no prompt -> NULL");
+ CHECK(err && strstr(err, "prompt"), "names the prompt");
+ free(err);
+ freellmcfg(&c);
+}
+
+static void test_llm_tool_bad_schema(void) {
+ config c = mkllmcfg();
+ llm_tool_call call;
+ memset(&call, 0, sizeof call);
+ call.prompt = "hi";
+ call.response_schema = "not-json";
+ char *err = NULL;
+ CHECK(tool_llm(&call, &c, NULL, &err) == NULL, "bad schema -> NULL");
+ CHECK(err && strstr(err, "response_schema"), "names the schema");
+ free(err);
+ freellmcfg(&c);
+}
+
+static void test_llm_tool_null_call(void) {
+ config c = mkllmcfg();
+ char *err = NULL;
+ CHECK(tool_llm(NULL, &c, NULL, &err) == NULL, "null call -> NULL");
+ CHECK(err && strstr(err, "prompt"), "names the prompt");
+ free(err);
+ freellmcfg(&c);
+}
+
+int main(void) {
+ test_read_write_roundtrip();
+ test_read_file_missing_errors();
+ test_read_file_empty_path_errors();
+ test_read_file_too_large();
+ test_read_file_binary_rejected();
+ test_read_file_offset_limit();
+ test_read_file_offset_after_unterminated_last_line();
+ test_read_file_rejects_embedded_nul();
+ test_write_file_rejects_missing_content();
+ test_run_shell_success_output();
+ test_run_shell_success_no_output();
+ test_run_shell_failure_status();
+ test_run_shell_stderr_section();
+ test_run_shell_signal();
+ test_run_shell_denylist_blocks();
+ test_run_shell_allowlist_requires_match();
+ test_run_shell_confirm_hook_aborts();
+ test_run_shell_timeout_kills_process_group();
+ test_run_shell_large_output_does_not_hang();
+ test_truncate_output();
+ test_tool_list();
+ test_display_vtable();
+ test_tool_params_schemas();
+ test_tokenize();
+ test_is_subsequence();
+ test_field_match_strength();
+ test_memory_relevance();
+ test_search_memories_ranks_relevance();
+ test_search_memories_empty_query();
+ test_use_skill_and_search_memories_workspace();
+ test_tools_read_snapshot_not_disk();
+ test_write_file_creates_parents();
+ test_embed_resolve_profile_values();
+ test_embed_resolve_falls_back_to_top_level();
+ test_embed_resolve_errors();
+ test_embed_request_body();
+ test_embed_url();
+ test_embed_parse();
+ test_embed_tool_unset();
+ test_llm_tool_schemas();
+ test_llm_tool_requires_prompt();
+ test_llm_tool_bad_schema();
+ test_llm_tool_null_call();
+
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}
diff --git a/valgrind.supp b/valgrind.supp
new file mode 100644
index 0000000..194c7ed
--- /dev/null
+++ b/valgrind.supp
@@ -0,0 +1,115 @@
+# OpenSSL process-global state allocated during curl_global_init (TLS backend
+# init: namemaps, providers, cipher tables). Retained until process exit; not
+# a real leak. "Still reachable" only - real leaks (definite/indirect/possible)
+# are still reported and fail the gate via --error-exitcode.
+{
+ openssl_crypto_malloc
+ Memcheck:Leak
+ match-leak-kinds: reachable
+ fun:malloc
+ ...
+ obj:*crypto*
+ ...
+}
+
+{
+ openssl_crypto_realloc
+ Memcheck:Leak
+ match-leak-kinds: reachable
+ fun:realloc
+ ...
+ obj:*crypto*
+ ...
+}
+
+{
+ openssl_crypto_posix_memalign
+ Memcheck:Leak
+ match-leak-kinds: reachable
+ fun:posix_memalign
+ ...
+ obj:*crypto*
+ ...
+}
+
+{
+ openssl_crypto_calloc
+ Memcheck:Leak
+ match-leak-kinds: reachable
+ fun:calloc
+ ...
+ obj:*crypto*
+ ...
+}
+
+{
+ openssl_crypto_aligned_alloc
+ Memcheck:Leak
+ match-leak-kinds: reachable
+ fun:aligned_alloc
+ ...
+ obj:*crypto*
+ ...
+}
+
+{
+ openssl_ssl_malloc
+ Memcheck:Leak
+ match-leak-kinds: reachable
+ fun:malloc
+ ...
+ obj:*ssl*
+ ...
+}
+
+{
+ openssl_ssl_realloc
+ Memcheck:Leak
+ match-leak-kinds: reachable
+ fun:realloc
+ ...
+ obj:*ssl*
+ ...
+}
+
+{
+ openssl_ssl_posix_memalign
+ Memcheck:Leak
+ match-leak-kinds: reachable
+ fun:posix_memalign
+ ...
+ obj:*ssl*
+ ...
+}
+
+{
+ openssl_ssl_calloc
+ Memcheck:Leak
+ match-leak-kinds: reachable
+ fun:calloc
+ ...
+ obj:*ssl*
+ ...
+}
+
+{
+ openssl_ssl_aligned_alloc
+ Memcheck:Leak
+ match-leak-kinds: reachable
+ fun:aligned_alloc
+ ...
+ obj:*ssl*
+ ...
+}
+
+# Thread-local storage (Dtv) allocated by glibc for each pthread_create.
+# Retained until process exit; not a real leak. "Possibly lost" only.
+{
+ pthread_tls
+ Memcheck:Leak
+ match-leak-kinds: possible
+ fun:calloc
+ ...
+ fun:allocate_stack
+ ...
+}
diff --git a/vendor/src/cJSON-1.7.19.tar.gz b/vendor/src/cJSON-1.7.19.tar.gz
new file mode 100644
index 0000000..7f5a46b
--- /dev/null
+++ b/vendor/src/cJSON-1.7.19.tar.gz
Binary files differ
diff --git a/vendor/src/curl-8.10.1.tar.gz b/vendor/src/curl-8.10.1.tar.gz
new file mode 100644
index 0000000..e47652e
--- /dev/null
+++ b/vendor/src/curl-8.10.1.tar.gz
Binary files differ
diff --git a/vendor/src/musl-1.2.5.tar.gz b/vendor/src/musl-1.2.5.tar.gz
new file mode 100644
index 0000000..7af3adc
--- /dev/null
+++ b/vendor/src/musl-1.2.5.tar.gz
Binary files differ
diff --git a/vendor/src/nlohmann-json-3.12.0.tar.gz b/vendor/src/nlohmann-json-3.12.0.tar.gz
new file mode 100644
index 0000000..f17f79f
--- /dev/null
+++ b/vendor/src/nlohmann-json-3.12.0.tar.gz
Binary files differ
diff --git a/vendor/src/openssl-3.3.2.tar.gz b/vendor/src/openssl-3.3.2.tar.gz
new file mode 100644
index 0000000..eeccaf3
--- /dev/null
+++ b/vendor/src/openssl-3.3.2.tar.gz
Binary files differ
diff --git a/vendor/src/zlib-1.3.1.tar.gz b/vendor/src/zlib-1.3.1.tar.gz
new file mode 100644
index 0000000..53fa48b
--- /dev/null
+++ b/vendor/src/zlib-1.3.1.tar.gz
Binary files differ