summaryrefslogtreecommitdiffstats
path: root/src
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 /src
initial commitHEADmaster
Diffstat (limited to 'src')
-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
31 files changed, 15433 insertions, 0 deletions
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