diff options
Diffstat (limited to 'src/main.cpp')
| -rw-r--r-- | src/main.cpp | 1902 |
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, >, &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; +} |