summaryrefslogtreecommitdiffstats
path: root/src/main.cpp
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/main.cpp
initial commitHEADmaster
Diffstat (limited to 'src/main.cpp')
-rw-r--r--src/main.cpp1902
1 files changed, 1902 insertions, 0 deletions
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;
+}