/* llsh — a terminal LLM agent harness (C port). */ #include #include #include #include #include #include #include #include #include #include #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 *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//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 *stop; char *err; int result; std::atomic 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 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 )\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 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; }