Files
CST-agent/src/webui.c
T

2813 lines
97 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* WebUI: the HTTP + SSE front end (see include/cst_webui.h).
*
* Threading: every HTTP connection runs on its own thread (src/http_srv.c),
* a turn runs on its own worker thread, and the agent itself is only ever
* touched from the turn thread or while holding w->mu. The hooks the agent
* calls live on the turn thread: they append to the event ring, wake the
* event-stream threads and, for permission/ask_user, block until the
* browser answers (or the timeout denies it). Nothing in the engine was
* changed to make this work - the hooks are the interface the TUI uses too. */
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include "cst_agent.h"
#include "cst_attach.h"
#include "cst_ext.h"
#include "cst_http.h"
#include "cst_mcp.h"
#include "cst_models.h"
#include "cst_perms.h"
#include "cst_plugins.h"
#include "cst_provider_switch.h"
#include "cst_sandbox.h"
#include "cst_skills.h"
#include "cst_subagent.h"
#include "cst_runtime.h"
#include "cst_worktree.h"
#include "cst_tools.h"
#include "cst_util.h"
#include "cst_web_md.h"
#include "cst_webui.h"
#include "cJSON.h"
#include "mm_tool.h"
#include "os_compat.h"
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#define WUI_RING 2000
#define WUI_TOOL_OUT 32768
#define WUI_ASK_MAX 1024
typedef struct {
const char *name;
const char *ctype;
const unsigned char *data;
size_t len;
} wui_asset_t;
#include "web_assets.inc"
typedef struct wui_sse {
struct wui *w;
cst_http_conn_t *c;
uint64_t cursor;
pthread_mutex_t mu;
pthread_cond_t cv;
int closed;
struct wui_sse *next;
} wui_sse_t;
typedef struct wui {
cst_agent_t *agent;
cst_http_server_t *srv;
char host[64];
char token[64];
int need_auth;
const char *root; /* --web-dev: serve assets from disk */
int perm_timeout_s;
int show_thinking; /* settings.json: reasoning starts expanded */
pthread_mutex_t mu;
pthread_cond_t cv; /* permission / ask_user answers */
cst_web_md_t *md; /* live markdown for the running turn */
/* event ring */
uint64_t seq;
struct {
uint64_t seq;
char *frame;
} ring[WUI_RING];
wui_sse_t *subs;
int busy;
int op_busy; /* a session/workspace/undo/compact/attach call runs */
int turn_live; /* the turn thread still uses w; cleared as it exits */
uint64_t req_id; /* permission/ask id counter */
uint64_t perm_id;
int perm_active, perm_answer;
uint64_t ask_id;
int ask_active, ask_rc; /* ask_rc: 0 pending, 1 answered, -1 dismissed */
uint64_t pending_seq; /* event of the open permission/ask_user, 0 = none */
char ask_ans[WUI_ASK_MAX];
/* turn thread only: the tail of a UTF-8 sequence split across deltas,
* [0] think_delta, [1] assistant_delta */
cst_utf8_hold_t carry[2];
volatile int stopping;
} wui_t;
/* --- events --------------------------------------------------------------- */
static uint64_t wui_first_seq(const wui_t *w) {
return w->seq > WUI_RING ? w->seq - WUI_RING + 1 : 1;
}
/* Owns data (a cJSON object). Returns the event's seq, 0 when dropped. */
static uint64_t wui_emit(wui_t *w, const char *type, cJSON *data) {
if (!data)
data = cJSON_CreateObject();
if (!data)
return 0;
cJSON_AddStringToObject(data, "type", type);
/* the seq in the payload must match the SSE id, so it is taken under mu */
pthread_mutex_lock(&w->mu);
cJSON_AddNumberToObject(data, "seq", (double)(w->seq + 1));
char *json = cJSON_PrintUnformatted(data);
cJSON_Delete(data);
cst_buf_t f = {0};
if (json)
cst_buf_printf(&f, "event: %s\ndata: %s\n\n", type, json);
free(json);
if (!f.buf) {
pthread_mutex_unlock(&w->mu);
cst_buf_free(&f);
return 0;
}
uint64_t seq = ++w->seq;
int slot = (int)(seq % WUI_RING);
free(w->ring[slot].frame);
w->ring[slot].frame = f.buf; /* stolen */
w->ring[slot].seq = seq;
f.buf = NULL;
for (wui_sse_t *s = w->subs; s; s = s->next)
pthread_cond_broadcast(&s->cv);
pthread_mutex_unlock(&w->mu);
cst_buf_free(&f);
return seq;
}
static void wui_set_pending(wui_t *w, uint64_t seq) {
pthread_mutex_lock(&w->mu);
w->pending_seq = seq;
pthread_mutex_unlock(&w->mu);
}
static void wui_emit_str(wui_t *w, const char *type, const char *key,
const char *val) {
cJSON *o = cJSON_CreateObject();
if (!o)
return;
cJSON_AddStringToObject(o, key, val ? val : "");
wui_emit(w, type, o);
}
static void wui_emit_text(wui_t *w, const char *type, const char *text,
size_t len) {
cJSON *o = cJSON_CreateObject();
if (!o)
return;
char *s = malloc(len + 1);
if (!s) {
cJSON_Delete(o);
return;
}
memcpy(s, text, len);
s[len] = 0;
cJSON_AddStringToObject(o, "text", s);
free(s);
wui_emit(w, type, o);
}
/* A JSON string with half a character turns into U+FFFD in the browser:
* hold an incomplete trailing sequence until the next delta completes it. */
static void wui_emit_stream(wui_t *w, int k, const char *type,
const char *text, size_t len) {
size_t n = 0;
char *s = cst_utf8_hold_feed(&w->carry[k], text, len, &n);
if (s && n)
wui_emit_text(w, type, s, n);
free(s);
}
static void wui_reset_carry(wui_t *w) {
w->carry[0].n = w->carry[1].n = 0;
}
/* --- state --------------------------------------------------------------- */
/* Whether provider has a key and where it comes from: "saved", the env
* variable's name, or "session" (given for this run only). The key itself
* never leaves this function. */
static int key_source(const cst_agent_config_t *cfg, const char *provider,
char *from, size_t from_sz) {
char k[512];
const char *src = NULL;
int found = cst_creds_resolve(provider, k, sizeof(k), &src) == 0;
int cur = cfg && strcmp(cfg->provider, provider) == 0;
int has = cur ? cfg->api_key[0] != 0 : found;
if (!has)
from[0] = 0;
else if (cur && (!found || strcmp(k, cfg->api_key) != 0))
snprintf(from, from_sz, "session");
else
snprintf(from, from_sz, "%s", src ? src : "saved");
memset(k, 0, sizeof(k));
return has;
}
static void model_info_json(cJSON *o, const char *provider,
const char *model) {
cst_model_info_t mi;
cst_model_info(provider, model, &mi);
char label[48];
cst_model_info_label(&mi, label, sizeof(label));
cJSON_AddStringToObject(o, "label", label);
cJSON_AddNumberToObject(o, "context", (double)mi.context);
if (mi.vision < 0)
cJSON_AddNullToObject(o, "vision");
else
cJSON_AddBoolToObject(o, "vision", mi.vision == 1);
cJSON_AddBoolToObject(o, "reasoning", mi.reasoning != 0);
cJSON_AddStringToObject(o, "source", mi.source);
if (mi.has_price) {
cJSON *p = cJSON_AddObjectToObject(o, "price");
if (p) {
cJSON_AddStringToObject(p, "currency", mi.price.currency);
cJSON_AddNumberToObject(p, "miss", mi.price.miss);
cJSON_AddNumberToObject(p, "hit", mi.price.hit);
cJSON_AddNumberToObject(p, "out", mi.price.out);
}
}
}
static void usage_json(cJSON *u, const cst_usage_t *s) {
cJSON_AddNumberToObject(u, "requests", s->requests);
cJSON_AddNumberToObject(u, "prompt_tokens", (double)s->prompt);
cJSON_AddNumberToObject(u, "completion_tokens", (double)s->out);
cJSON_AddNumberToObject(u, "cached_tokens", (double)s->cached);
cJSON_AddNumberToObject(u, "cache_miss_tokens", (double)s->miss);
cJSON_AddNumberToObject(u, "cost", s->cost);
cJSON_AddBoolToObject(u, "priced", s->priced != 0);
cJSON_AddStringToObject(u, "currency", s->currency);
}
static cJSON *wui_state_new(wui_t *w) {
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
cJSON *o = cJSON_CreateObject();
if (!o || !cfg) {
cJSON_Delete(o);
return NULL;
}
char sid[128] = "";
cst_agent_session_id(w->agent, sid, sizeof(sid));
cst_usage_t sess = {0}, turn = {0};
cst_agent_usage(w->agent, &sess, &turn);
cJSON_AddStringToObject(o, "protocol", "cst-webui/1");
cJSON_AddStringToObject(o, "workspace", cfg->workspace_root);
cJSON_AddStringToObject(o, "session", sid);
cJSON_AddStringToObject(o, "provider", cfg->provider);
cJSON_AddStringToObject(o, "model", cfg->model);
cJSON_AddStringToObject(o, "effort", cfg->effort);
cJSON_AddStringToObject(o, "mode", cst_mode_name(cfg->mode));
cJSON_AddStringToObject(o, "approval", cst_approval_name(cfg->approval));
cJSON_AddStringToObject(o, "protocol_tools", cst_protocol_name(cfg->protocol));
cJSON_AddBoolToObject(o, "plan_mode", cfg->plan_mode);
cJSON_AddBoolToObject(o, "show_thinking", w->show_thinking);
cJSON_AddBoolToObject(o, "status_busy", w->busy);
cJSON_AddStringToObject(o, "todo", cst_agent_todo(w->agent)
? cst_agent_todo(w->agent)
: "");
cJSON_AddNumberToObject(o, "context_tokens",
(double)cst_agent_last_prompt_tokens(w->agent));
cJSON_AddBoolToObject(o, "undo_available",
cst_agent_undo_available(w->agent) != 0);
cJSON *u = cJSON_AddObjectToObject(o, "usage");
if (u)
usage_json(u, &sess);
u = cJSON_AddObjectToObject(o, "turn_usage");
if (u)
usage_json(u, &turn);
cJSON_AddStringToObject(o, "api_base", cfg->api_base);
char from[64];
cJSON_AddBoolToObject(o, "key_set",
key_source(cfg, cfg->provider, from, sizeof(from)));
cJSON_AddStringToObject(o, "key_from", from);
cJSON *mi = cJSON_AddObjectToObject(o, "model_info");
if (mi)
model_info_json(mi, cfg->provider, cfg->model);
cJSON_AddBoolToObject(o, "effort_applies",
cst_effort_wire(cfg->provider, cfg->model,
cfg->effort) != NULL);
/* 1 yes, 0 no, -1 not known */
cJSON_AddNumberToObject(o, "sees_images", cst_agent_sees_images(w->agent));
cJSON_AddNumberToObject(o, "max_steps", cfg->max_steps);
cJSON_AddStringToObject(o, "tool_profile",
cst_tool_profile_name(cfg->tool_profile));
cJSON_AddNumberToObject(o, "perm_timeout",
w->perm_timeout_s > 0 ? w->perm_timeout_s : 600);
cJSON *imgs = cJSON_AddArrayToObject(o, "pending_images");
int n = cst_agent_pending_count(w->agent);
for (int i = 0; i < n && imgs; i++) {
char line[160];
if (cst_agent_pending_describe(w->agent, i, line, sizeof(line)) == 0)
cJSON_AddItemToArray(imgs, cJSON_CreateString(line));
}
return o;
}
static void wui_emit_state(wui_t *w) {
cJSON *o = wui_state_new(w);
if (o)
wui_emit(w, "state", o);
}
/* --- session -------------------------------------------------------------- */
static cJSON *call_json(const ctl_tool_call_t *call);
/* A text-protocol reply keeps its call frames in content: the page gets the
* prose and the calls apart. */
typedef struct {
cst_buf_t text;
cJSON *calls;
int bad;
} wui_split_t;
static void split_ev(const ctl_event_t *ev, void *ud) {
wui_split_t *s = ud;
if (ev->type == CTL_EV_TEXT)
cst_buf_append(&s->text, ev->text, ev->text_len);
else if (ev->type == CTL_EV_CALL_READY && ev->call)
cJSON_AddItemToArray(s->calls, call_json(ev->call));
else if (ev->type == CTL_EV_ERROR)
s->bad = 1;
}
static int msg_protocol(const cst_message_t *m, cst_protocol_t *p) {
cJSON *o = m->meta_json ? cJSON_Parse(m->meta_json) : NULL;
const cJSON *pr = cJSON_GetObjectItemCaseSensitive(o, "protocol");
int rc = cJSON_IsString(pr) ? cst_protocol_parse(pr->valuestring, p) : -1;
cJSON_Delete(o);
return rc;
}
/* Visible text (malloc'd) and calls of a text-protocol reply; NULL when the
* frames do not parse, and the raw text is shown as it is. */
static char *split_reply(const char *s, cst_protocol_t p, cJSON **calls) {
wui_split_t sp;
memset(&sp, 0, sizeof(sp));
sp.calls = cJSON_CreateArray();
if (!sp.calls)
return NULL;
size_t n = strlen(s);
if (p == CST_PROTO_XML) {
mm_scan_t *sc = mm_scan_create(split_ev, &sp);
sp.bad = !sc;
if (sc) {
mm_scan_feed(sc, s, n);
mm_scan_finish(sc);
mm_scan_destroy(sc);
}
} else {
ctl_parser_t *cp = ctl_parser_create(split_ev, &sp);
sp.bad = !cp;
if (cp) {
ctl_parser_feed(cp, s, n);
ctl_parser_finish(cp);
ctl_parser_destroy(cp);
}
}
if (sp.bad || !cJSON_GetArraySize(sp.calls)) {
cst_buf_free(&sp.text);
cJSON_Delete(sp.calls);
return NULL;
}
*calls = sp.calls;
char *t = cst_buf_steal(&sp.text);
return t ? t : strdup("");
}
/* Native tool_calls as [{id, name, args}], args parsed when they are JSON. */
static cJSON *native_calls(const char *tool_calls_json) {
cJSON *in = cJSON_Parse(tool_calls_json);
cJSON *out = cJSON_IsArray(in) ? cJSON_CreateArray() : NULL;
const cJSON *src = out ? in : NULL;
const cJSON *it;
cJSON_ArrayForEach(it, src) {
const cJSON *fn = cJSON_GetObjectItemCaseSensitive(it, "function");
const cJSON *id = cJSON_GetObjectItemCaseSensitive(it, "id");
const cJSON *nm = cJSON_GetObjectItemCaseSensitive(fn, "name");
const cJSON *ar = cJSON_GetObjectItemCaseSensitive(fn, "arguments");
cJSON *o = cJSON_CreateObject();
if (!o)
continue;
cJSON_AddStringToObject(o, "id", cJSON_IsString(id) ? id->valuestring : "");
cJSON_AddStringToObject(o, "name", cJSON_IsString(nm) ? nm->valuestring : "");
cJSON *args = cJSON_IsString(ar) ? cJSON_Parse(ar->valuestring) : NULL;
if (cJSON_IsObject(args))
cJSON_AddItemToObject(o, "args", args);
else {
cJSON_Delete(args);
cJSON_AddItemToObject(o, "args", cJSON_CreateObject());
}
cJSON_AddItemToArray(out, o);
}
cJSON_Delete(in);
return out;
}
/* Body and exit code of a stored tool result: "[tool NAME id=ID exit=N]\n"
* (native) or "result ID exit=N LEN\n...\nend" (text protocols). */
static void tool_out_view(const char *s, const char **body, size_t *len,
int *exit_code) {
size_t n = strlen(s);
*exit_code = 0;
int framed = strncmp(s, "result ", 7) == 0;
if (framed || strncmp(s, "[tool ", 6) == 0) {
const char *nl = strchr(s, '\n');
const char *ex = strstr(s, " exit=");
if (ex && (!nl || ex < nl))
*exit_code = atoi(ex + 6);
if (nl) {
n -= (size_t)(nl + 1 - s);
s = nl + 1;
} else {
n = 0;
}
if (framed && n >= 4 && strcmp(s + n - 4, "\nend") == 0)
n -= 4;
else if (framed && n == 3 && strcmp(s, "end") == 0)
n = 0;
}
*body = s;
*len = n;
}
static cJSON *msg_json(const cst_message_t *m) {
cJSON *o = cJSON_CreateObject();
if (!o)
return NULL;
const char *role = "user";
switch (m->role) {
case CST_ROLE_SYSTEM: role = "system"; break;
case CST_ROLE_ASSISTANT: role = "assistant"; break;
case CST_ROLE_TOOL: role = "tool"; break;
default: role = "user"; break;
}
cJSON_AddStringToObject(o, "role", role);
cJSON_AddStringToObject(o, "content", m->content ? m->content : "");
cJSON *calls = NULL;
char *prose = NULL;
cst_protocol_t proto;
if (m->role == CST_ROLE_ASSISTANT && m->tool_calls_json &&
m->tool_calls_json[0])
calls = native_calls(m->tool_calls_json);
else if (m->role == CST_ROLE_ASSISTANT && m->content && m->content[0] &&
msg_protocol(m, &proto) == 0 && cst_protocol_is_text(proto))
prose = split_reply(m->content, proto, &calls);
if (prose)
cJSON_AddStringToObject(o, "text", prose);
if (calls)
cJSON_AddItemToObject(o, "calls", calls);
const char *shown = prose ? prose : m->content;
if (m->role == CST_ROLE_ASSISTANT && shown && shown[0]) {
char *html = cst_web_md_html(shown, strlen(shown));
if (html) {
cJSON_AddStringToObject(o, "html", html);
free(html);
}
}
free(prose);
if (cst_message_is_tool_result(m)) {
const char *body;
size_t blen;
int ex;
tool_out_view(m->content ? m->content : "", &body, &blen, &ex);
size_t cap = blen > 32 * 1024 ? cst_utf8_floor(body, 32 * 1024) : blen;
char *out = malloc(cap + 1);
if (out) {
memcpy(out, body, cap);
out[cap] = 0;
cJSON_AddStringToObject(o, "out", out);
free(out);
}
cJSON_AddBoolToObject(o, "truncated", cap < blen);
cJSON_AddNumberToObject(o, "exit", ex);
}
if (m->reasoning && m->reasoning[0])
cJSON_AddStringToObject(o, "reasoning", m->reasoning);
if (cst_message_is_note(m))
cJSON_AddBoolToObject(o, "note", 1);
if (cst_message_is_tool_result(m))
cJSON_AddBoolToObject(o, "tool_result", 1);
if (m->tool_call_id[0])
cJSON_AddStringToObject(o, "tool_call_id", m->tool_call_id);
if (m->tool_name[0])
cJSON_AddStringToObject(o, "tool_name", m->tool_name);
if (m->tool_calls_json && m->tool_calls_json[0]) {
cJSON *tc = cJSON_Parse(m->tool_calls_json);
if (tc) {
cJSON_AddItemToObject(o, "tool_calls", tc);
}
}
if (m->nattach > 0) {
cJSON *arr = cJSON_AddArrayToObject(o, "attach");
for (int i = 0; arr && i < m->nattach; i++) {
cJSON *a = cJSON_CreateObject();
if (!a)
continue;
cJSON_AddStringToObject(a, "name", m->attach[i].name);
cJSON_AddStringToObject(a, "mime", m->attach[i].mime);
cJSON_AddStringToObject(a, "rel", m->attach[i].rel);
cJSON_AddNumberToObject(a, "bytes", (double)m->attach[i].bytes);
cJSON_AddItemToArray(arr, a);
}
}
return o;
}
static char *wui_session_json(wui_t *w, int from) {
cst_session_t *s = cst_agent_session(w->agent);
if (!s)
return NULL;
cJSON *root = cJSON_CreateObject();
if (!root)
return NULL;
cJSON *arr = cJSON_AddArrayToObject(root, "messages");
cst_session_lock(s);
int n = cst_session_count(s);
for (int i = from; i < n; i++) {
const cst_message_t *m = cst_session_get(s, i);
if (!m)
continue;
cJSON *j = msg_json(m);
if (j && arr)
cJSON_AddItemToArray(arr, j);
else
cJSON_Delete(j);
}
cst_session_unlock(s);
char *out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
/* --- hooks --------------------------------------------------------------- */
static int wui_md_emit(const char *html, size_t len, void *ud) {
wui_t *w = ud;
if (len)
wui_emit_text(w, "assistant_html", html, len);
return 0;
}
static void on_status(const char *msg, void *ud) {
wui_emit_str((wui_t *)ud, "status", "message", msg);
}
static void on_assistant_delta(const char *text, size_t len, void *ud) {
wui_t *w = ud;
if (!w->md)
w->md = cst_web_md_new(wui_md_emit, w);
if (w->md)
cst_web_md_feed(w->md, text, len);
wui_emit_stream(w, 1, "assistant_delta", text, len);
}
static void on_think_delta(const char *text, size_t len, void *ud) {
wui_emit_stream((wui_t *)ud, 0, "think_delta", text, len);
}
static cJSON *call_json(const ctl_tool_call_t *call) {
cJSON *o = cJSON_CreateObject();
if (!o)
return NULL;
cJSON_AddStringToObject(o, "id", call->id);
cJSON_AddStringToObject(o, "name", call->name);
cJSON *args = cJSON_AddObjectToObject(o, "args");
for (int i = 0; args && i < call->nargs; i++) {
cJSON *v = cJSON_CreateString(call->args[i].value ? call->args[i].value : "");
if (v)
cJSON_AddItemToObject(args, call->args[i].key, v);
}
return o;
}
static void on_tool_event(const char *phase, const ctl_tool_call_t *call,
const char *detail, void *ud) {
wui_t *w = ud;
cJSON *o = call ? call_json(call) : cJSON_CreateObject();
if (!o)
return;
cJSON_AddStringToObject(o, "phase", phase ? phase : "");
if (detail)
cJSON_AddStringToObject(o, "detail", detail);
wui_emit(w, "tool", o);
}
static void on_tool_result(const ctl_tool_call_t *call, const char *out,
size_t len, int exit_code, void *ud) {
wui_t *w = ud;
cJSON *o = call ? call_json(call) : cJSON_CreateObject();
if (!o)
return;
size_t shown = len > WUI_TOOL_OUT ? WUI_TOOL_OUT : len;
char *s = malloc(shown + 1);
if (!s) {
cJSON_Delete(o);
return;
}
memcpy(s, out, shown);
s[shown] = 0;
cJSON_AddStringToObject(o, "out", s);
cJSON_AddBoolToObject(o, "truncated", len > shown);
cJSON_AddNumberToObject(o, "bytes", (double)len);
free(s);
cJSON_AddNumberToObject(o, "exit", exit_code);
wui_emit(w, "tool_result", o);
}
static void on_todo(const char *text, void *ud) {
wui_emit_str((wui_t *)ud, "todo", "text", text ? text : "");
}
static int wui_wait_permission(wui_t *w, const char *tool, const char *detail,
const char *preview) {
pthread_mutex_lock(&w->mu);
w->req_id++;
uint64_t id = w->req_id;
w->perm_id = id;
w->perm_active = 1;
w->perm_answer = -1;
int has_client = w->subs != NULL;
pthread_mutex_unlock(&w->mu);
int timeout = w->perm_timeout_s > 0 ? w->perm_timeout_s : 600;
/* Nobody is watching: do not hang the turn for ten minutes. */
if (!has_client)
timeout = 3;
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddNumberToObject(o, "id", (double)id);
cJSON_AddStringToObject(o, "tool", tool ? tool : "tool");
cJSON_AddStringToObject(o, "detail", detail ? detail : "");
cJSON_AddStringToObject(o, "preview", preview ? preview : "");
cJSON_AddBoolToObject(o, "sandbox", detail &&
strncmp(detail, CST_PERM_SANDBOX,
strlen(CST_PERM_SANDBOX)) ==
0);
cJSON_AddNumberToObject(o, "timeout", timeout);
wui_set_pending(w, wui_emit(w, "permission", o));
}
int answer = CST_PERM_DENY;
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += timeout;
pthread_mutex_lock(&w->mu);
for (;;) {
if (w->perm_active && w->perm_id == id && w->perm_answer >= 0) {
answer = w->perm_answer;
w->perm_active = 0;
break;
}
if (w->stopping)
break;
int rc = pthread_cond_timedwait(&w->cv, &w->mu, &deadline);
if (rc != 0) {
w->perm_active = 0;
break;
}
}
w->perm_active = 0;
w->pending_seq = 0;
pthread_mutex_unlock(&w->mu);
cJSON *e = cJSON_CreateObject();
if (e) {
cJSON_AddNumberToObject(e, "id", (double)id);
cJSON_AddNumberToObject(e, "answer", answer);
wui_emit(w, "permission_done", e);
}
return answer;
}
static int on_permission(const char *tool_name, const char *detail,
const char *preview, void *ud) {
return wui_wait_permission((wui_t *)ud, tool_name, detail, preview);
}
static int on_ask_user(const char *question, const char *options, char *ans,
size_t ans_sz, void *ud) {
wui_t *w = ud;
pthread_mutex_lock(&w->mu);
w->req_id++;
uint64_t id = w->req_id;
w->ask_id = id;
w->ask_active = 1;
w->ask_rc = 0;
w->ask_ans[0] = 0;
int has_client = w->subs != NULL;
pthread_mutex_unlock(&w->mu);
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddNumberToObject(o, "id", (double)id);
cJSON_AddStringToObject(o, "question", question ? question : "");
cJSON *arr = cJSON_AddArrayToObject(o, "options");
for (const char *p = options; arr && p && *p;) {
const char *nl = strchr(p, '\n');
size_t n = nl ? (size_t)(nl - p) : strlen(p);
char *tmp = malloc(n + 1);
if (tmp) {
memcpy(tmp, p, n);
tmp[n] = 0;
cJSON_AddItemToArray(arr, cJSON_CreateString(tmp));
free(tmp);
}
p = nl ? nl + 1 : NULL;
}
wui_set_pending(w, wui_emit(w, "ask_user", o));
}
int timeout = has_client ? 600 : 3;
int rc = -1;
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += timeout;
pthread_mutex_lock(&w->mu);
for (;;) {
if (w->ask_active && w->ask_id == id && w->ask_rc != 0) {
rc = w->ask_rc == 1 ? 0 : -1;
snprintf(ans, ans_sz, "%s", w->ask_ans);
w->ask_active = 0;
break;
}
if (w->stopping)
break;
int wrc = pthread_cond_timedwait(&w->cv, &w->mu, &deadline);
if (wrc != 0) {
w->ask_active = 0;
break;
}
}
w->ask_active = 0;
w->pending_seq = 0;
pthread_mutex_unlock(&w->mu);
return rc;
}
/* --- routes -------------------------------------------------------------- */
static int read_file_all(const char *path, char **out, size_t *out_len) {
FILE *fp = fopen(path, "rb");
if (!fp)
return -1;
cst_buf_t b = {0};
char tmp[8192];
size_t n;
while ((n = fread(tmp, 1, sizeof(tmp), fp)) > 0) {
if (cst_buf_append(&b, tmp, n) != 0) {
fclose(fp);
cst_buf_free(&b);
return -1;
}
}
fclose(fp);
size_t total = b.len;
*out = cst_buf_steal(&b);
if (!*out)
return -1;
*out_len = total;
return 0;
}
static int wui_serve_file(cst_http_conn_t *c, wui_t *w, const char *path) {
if (!w->root || !path || !path[0] || strstr(path, ".."))
return -1;
char full[4096];
const char *rel = path[0] == '/' ? path + 1 : path;
if (!rel[0])
rel = "index.html";
if (snprintf(full, sizeof(full), "%s/%s", w->root, rel) >=
(int)sizeof(full))
return -1;
char *data = NULL;
size_t len = 0;
if (read_file_all(full, &data, &len) != 0)
return -1;
const char *ctype = "application/octet-stream";
size_t n = strlen(rel);
if (n > 5 && strcmp(rel + n - 5, ".html") == 0)
ctype = "text/html; charset=utf-8";
else if (n > 3 && strcmp(rel + n - 3, ".js") == 0)
ctype = "text/javascript; charset=utf-8";
else if (n > 4 && strcmp(rel + n - 4, ".css") == 0)
ctype = "text/css; charset=utf-8";
else if (n > 4 && strcmp(rel + n - 4, ".svg") == 0)
ctype = "image/svg+xml";
int rc = cst_http_send(c, 200, ctype, data, len);
free(data);
return rc;
}
static int wui_serve_asset(cst_http_conn_t *c, wui_t *w, const char *path) {
const char *rel = path[0] == '/' ? path + 1 : path;
if (!rel[0])
rel = "index.html";
if (w->root)
return wui_serve_file(c, w, path);
for (int i = 0; i < wui_asset_count; i++) {
if (strcmp(wui_assets[i].name, rel) == 0)
return cst_http_send(c, 200, wui_assets[i].ctype, wui_assets[i].data,
wui_assets[i].len);
}
return -1;
}
static int wui_auth(const cst_http_req_t *req, void *ud);
static int h_asset(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_http_req_t *req = cst_http_request(c);
/* Stored session images: /api/attach/<rel>. The name is validated before
* it can touch the filesystem, and only the session's own directory is
* reachable. */
static const char prefix[] = "/api/attach/";
if (strncmp(req->path, prefix, sizeof(prefix) - 1) == 0) {
/* the fallback route is public (the page's own assets); images are not */
if (wui_auth(req, w) != 0)
return cst_http_send_err(c, 401, "unauthorized");
const char *rel = req->path + sizeof(prefix) - 1;
if (!cst_attach_rel_ok(rel))
return -1;
const char *sess = cst_agent_session_path(w->agent);
char dir[OS_PATH_MAX], full[OS_PATH_MAX];
if (!sess || cst_attach_dir(sess, dir, sizeof(dir)) != 0)
return -1;
if (snprintf(full, sizeof(full), "%s/%s", dir, rel) >= (int)sizeof(full))
return -1;
char *data = NULL;
size_t len = 0;
if (read_file_all(full, &data, &len) != 0)
return -1;
const char *ctype = "application/octet-stream";
size_t n = strlen(rel);
if (n > 4 && strcmp(rel + n - 4, ".png") == 0)
ctype = "image/png";
else if ((n > 4 && strcmp(rel + n - 4, ".jpg") == 0) ||
(n > 5 && strcmp(rel + n - 5, ".jpeg") == 0))
ctype = "image/jpeg";
else if (n > 4 && strcmp(rel + n - 4, ".gif") == 0)
ctype = "image/gif";
else if (n > 5 && strcmp(rel + n - 5, ".webp") == 0)
ctype = "image/webp";
else if (n > 4 && strcmp(rel + n - 4, ".bmp") == 0)
ctype = "image/bmp";
int rc = cst_http_send(c, 200, ctype, data, len);
free(data);
return rc;
}
return wui_serve_asset(c, w, req->path);
}
/* --- API handlers -------------------------------------------------------- */
static int h_state(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
pthread_mutex_lock(&w->mu);
cJSON *o = wui_state_new(w);
pthread_mutex_unlock(&w->mu);
if (!o)
return cst_http_send_err(c, 500, "out of memory");
char *json = cJSON_PrintUnformatted(o);
cJSON_Delete(o);
if (!json)
return cst_http_send_err(c, 500, "out of memory");
int rc = cst_http_send_json(c, 200, json);
free(json);
return rc;
}
static int h_session(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
char buf[32];
int from = 0;
if (cst_http_query(cst_http_request(c), "from", buf, sizeof(buf)) == 0)
from = atoi(buf);
if (from < 0)
from = 0;
char *json = wui_session_json(w, from);
if (!json)
return cst_http_send_err(c, 500, "out of memory");
int rc = cst_http_send_json(c, 200, json);
free(json);
return rc;
}
static int h_events(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_http_req_t *req = cst_http_request(c);
wui_sse_t s;
memset(&s, 0, sizeof(s));
s.w = w;
s.c = c;
s.cursor = 0;
char buf[32];
if (cst_http_query(req, "since", buf, sizeof(buf)) == 0)
s.cursor = (uint64_t)strtoull(buf, NULL, 10);
else if (req->last_event_id)
s.cursor = (uint64_t)strtoull(req->last_event_id, NULL, 10);
pthread_mutex_init(&s.mu, NULL);
pthread_cond_init(&s.cv, NULL);
pthread_mutex_lock(&w->mu);
/* 0 = a fresh client wants new events only; past the head = ids from an
* earlier server process */
if (s.cursor == 0 || s.cursor > w->seq) {
s.cursor = w->seq;
/* a page opened while a question waits must still get to answer it */
if (w->pending_seq && w->pending_seq <= w->seq)
s.cursor = w->pending_seq - 1;
}
if (s.cursor < wui_first_seq(w) - 1)
s.cursor = wui_first_seq(w) - 1; /* too far behind: resume at the head */
s.next = w->subs;
w->subs = &s;
pthread_mutex_unlock(&w->mu);
if (cst_http_stream_begin(c, "text/event-stream; charset=utf-8") != 0) {
goto done;
}
cst_http_stream_write(c, ": cst-agent webui\n\n", 19);
for (;;) {
if (cst_http_stopping(w->srv) || w->stopping || s.closed)
break;
cst_buf_t out = {0};
pthread_mutex_lock(&w->mu);
uint64_t last = w->seq;
uint64_t first = wui_first_seq(w);
int n = 0;
for (uint64_t q = s.cursor + 1; q <= last && n < 256; q++) {
if (q < first)
continue;
char *frame = w->ring[q % WUI_RING].frame;
if (!frame)
continue;
cst_buf_printf(&out, "id: %llu\n%s", (unsigned long long)q, frame);
n++;
s.cursor = q;
}
pthread_mutex_unlock(&w->mu);
if (n == 0) {
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += 1;
pthread_mutex_lock(&s.mu);
if (!s.closed)
pthread_cond_timedwait(&s.cv, &s.mu, &ts);
pthread_mutex_unlock(&s.mu);
cst_buf_free(&out);
if (cst_http_stream_write(c, ": ping\n\n", 8) != 0)
break;
continue;
}
int rc = cst_http_stream_write(c, out.buf, out.len);
cst_buf_free(&out);
if (rc != 0)
break;
}
done:
pthread_mutex_lock(&w->mu);
wui_sse_t **pp = &w->subs;
while (*pp && *pp != &s)
pp = &(*pp)->next;
if (*pp == &s)
*pp = s.next;
pthread_mutex_unlock(&w->mu);
pthread_mutex_destroy(&s.mu);
pthread_cond_destroy(&s.cv);
return 0;
}
typedef struct {
wui_t *w;
char *prompt;
} wui_turn_t;
static void wui_end_turn(wui_t *w, int rc, const char *err) {
/* Flush the markdown *outside* the lock: flushing emits the last HTML
* chunks, and wui_emit takes w->mu itself. */
pthread_mutex_lock(&w->mu);
cst_web_md_t *md = w->md;
w->md = NULL;
w->busy = 0;
pthread_mutex_unlock(&w->mu);
if (md) {
cst_web_md_finish(md);
cst_web_md_free(md);
}
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddNumberToObject(o, "rc", rc);
cJSON_AddStringToObject(o, "error", err ? err : "");
wui_emit(w, "turn_end", o);
}
wui_emit_state(w);
}
static void *wui_turn_main(void *arg) {
wui_turn_t *t = arg;
wui_t *w = t->w;
cst_agent_hooks_t hooks;
memset(&hooks, 0, sizeof(hooks));
hooks.on_status = on_status;
hooks.on_assistant_delta = on_assistant_delta;
hooks.on_think_delta = on_think_delta;
hooks.on_tool_event = on_tool_event;
hooks.on_tool_result = on_tool_result;
hooks.on_permission = on_permission;
hooks.on_ask_user = on_ask_user;
hooks.on_todo = on_todo;
hooks.userdata = w;
char err[512];
err[0] = 0;
wui_reset_carry(w);
int rc = cst_agent_run_turn_ex(w->agent, t->prompt, &hooks, err, sizeof(err));
wui_reset_carry(w); /* a dangling partial character is dropped */
wui_end_turn(w, rc, err);
free(t->prompt);
free(t);
pthread_mutex_lock(&w->mu);
w->turn_live = 0;
pthread_cond_broadcast(&w->cv);
pthread_mutex_unlock(&w->mu);
return NULL;
}
/* claimed: the caller holds op_busy (wui_claim); it passes to the turn, and is
* released either way. */
static int wui_start_turn(wui_t *w, const char *text, int claimed, char *err,
size_t err_sz) {
pthread_mutex_lock(&w->mu);
if (claimed)
w->op_busy = 0;
else if (w->busy || w->op_busy) {
pthread_mutex_unlock(&w->mu);
snprintf(err, err_sz, w->busy ? "a turn is already running"
: "another change is running");
return -1;
}
w->busy = 1;
if (w->md) {
cst_web_md_free(w->md);
w->md = NULL;
}
pthread_mutex_unlock(&w->mu);
wui_turn_t *t = calloc(1, sizeof(*t));
if (!t) {
pthread_mutex_lock(&w->mu);
w->busy = 0;
pthread_mutex_unlock(&w->mu);
snprintf(err, err_sz, "out of memory");
return -1;
}
t->w = w;
t->prompt = strdup(text ? text : "");
if (!t->prompt) {
free(t);
pthread_mutex_lock(&w->mu);
w->busy = 0;
pthread_mutex_unlock(&w->mu);
snprintf(err, err_sz, "out of memory");
return -1;
}
pthread_t th;
pthread_mutex_lock(&w->mu);
w->turn_live = 1;
pthread_mutex_unlock(&w->mu);
if (pthread_create(&th, NULL, wui_turn_main, t) != 0) {
free(t->prompt);
free(t);
pthread_mutex_lock(&w->mu);
w->busy = 0;
w->turn_live = 0;
pthread_mutex_unlock(&w->mu);
snprintf(err, err_sz, "cannot start the turn thread");
return -1;
}
pthread_detach(th);
wui_emit_state(w);
return 0;
}
static int h_turn(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_http_req_t *req = cst_http_request(c);
cJSON *body = req->body_len ? cJSON_Parse(req->body) : NULL;
const cJSON *text = body ? cJSON_GetObjectItem(body, "text") : NULL;
if (!body || !cJSON_IsString(text)) {
cJSON_Delete(body);
return cst_http_send_err(c, 400, "expected {\"text\": \"...\"}");
}
char err[256] = "";
int rc = wui_start_turn(w, text->valuestring, 0, err, sizeof(err));
cJSON_Delete(body);
if (rc != 0)
return cst_http_send_err(c, 409, err);
return cst_http_send_str(c, 202, "application/json; charset=utf-8",
"{\"ok\":true}");
}
/* The agent is not locked: switching sessions, the workspace, undo, compact
* and the pending images all change what a running turn reads. These run
* one at a time and never during a turn. */
static int wui_claim(wui_t *w) {
pthread_mutex_lock(&w->mu);
int ok = !w->busy && !w->op_busy;
if (ok)
w->op_busy = 1;
pthread_mutex_unlock(&w->mu);
return ok ? 0 : -1;
}
static void wui_release(wui_t *w) {
pthread_mutex_lock(&w->mu);
w->op_busy = 0;
pthread_mutex_unlock(&w->mu);
}
/* A copy of the config for a slow read (a model list fetch). Changes only
* write it after claiming under mu, so none is half done while mu is held
* and op_busy is clear; a running turn does not write these fields. */
static int wui_cfg_snapshot(wui_t *w, cst_agent_config_t *out) {
pthread_mutex_lock(&w->mu);
int ok = !w->op_busy;
if (ok)
*out = *cst_agent_config(w->agent);
pthread_mutex_unlock(&w->mu);
return ok ? 0 : -1;
}
static int wui_send_obj(cst_http_conn_t *c, int status, cJSON *o) {
char *json = o ? cJSON_PrintUnformatted(o) : NULL;
cJSON_Delete(o);
if (!json)
return cst_http_send_err(c, 500, "out of memory");
int rc = cst_http_send_json(c, status, json);
free(json);
return rc;
}
static cJSON *wui_body(cst_http_conn_t *c) {
const cst_http_req_t *req = cst_http_request(c);
return req->body_len ? cJSON_ParseWithLength(req->body, req->body_len)
: NULL;
}
static int wui_model_ok(const cJSON *m) {
if (!cJSON_IsString(m))
return 0;
size_t n = strlen(m->valuestring);
if (n == 0 || n > 127)
return 0;
for (size_t i = 0; i < n; i++)
if ((unsigned char)m->valuestring[i] < 0x21)
return 0;
return 1;
}
#define WUI_EXCLUSIVE(name) \
static int name(cst_http_conn_t *c, void *ud) { \
wui_t *w = ud; \
if (wui_claim(w) != 0) \
return cst_http_send_err(c, 409, "busy: wait for the running turn"); \
int rc = name##_x(c, ud); \
wui_release(w); \
return rc; \
}
static int h_cancel(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_request_cancel(w->agent);
/* a turn parked on the gate or a question would not see the flag */
pthread_mutex_lock(&w->mu);
if (w->perm_active && w->perm_answer < 0)
w->perm_answer = CST_PERM_DENY;
if (w->ask_active && w->ask_rc == 0)
w->ask_rc = -1;
pthread_cond_broadcast(&w->cv);
pthread_mutex_unlock(&w->mu);
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
"{\"ok\":true}");
}
static int h_permission(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_http_req_t *req = cst_http_request(c);
cJSON *body = req->body_len ? cJSON_Parse(req->body) : NULL;
const cJSON *id = body ? cJSON_GetObjectItem(body, "id") : NULL;
const cJSON *ans = body ? cJSON_GetObjectItem(body, "answer") : NULL;
int ok = 0;
if (cJSON_IsNumber(id) && cJSON_IsNumber(ans)) {
int answer = (int)ans->valuedouble;
if (answer >= CST_PERM_DENY && answer <= CST_PERM_ALWAYS_PREFIX) {
pthread_mutex_lock(&w->mu);
if (w->perm_active && w->perm_id == (uint64_t)id->valuedouble) {
w->perm_answer = answer;
pthread_cond_broadcast(&w->cv);
ok = 1;
}
pthread_mutex_unlock(&w->mu);
}
}
cJSON_Delete(body);
if (!ok)
return cst_http_send_err(c, 409, "no such permission request");
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
"{\"ok\":true}");
}
static int h_ask_user(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_http_req_t *req = cst_http_request(c);
cJSON *body = req->body_len ? cJSON_Parse(req->body) : NULL;
const cJSON *id = body ? cJSON_GetObjectItem(body, "id") : NULL;
const cJSON *text = body ? cJSON_GetObjectItem(body, "text") : NULL;
const cJSON *dismissed = body ? cJSON_GetObjectItem(body, "dismiss") : NULL;
int ok = 0;
if (cJSON_IsNumber(id)) {
pthread_mutex_lock(&w->mu);
if (w->ask_active && w->ask_id == (uint64_t)id->valuedouble) {
snprintf(w->ask_ans, sizeof(w->ask_ans), "%s",
cJSON_IsString(text) ? text->valuestring : "");
w->ask_rc = (dismissed && cJSON_IsTrue(dismissed)) ? -1 : 1;
pthread_cond_broadcast(&w->cv);
ok = 1;
}
pthread_mutex_unlock(&w->mu);
}
cJSON_Delete(body);
if (!ok)
return cst_http_send_err(c, 409, "no such question");
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
"{\"ok\":true}");
}
static int h_sessions(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
char (*ids)[128] = malloc(200 * sizeof(*ids)); /* handlers run in parallel */
if (!ids)
return cst_http_send_err(c, 500, "out of memory");
int n = cst_agent_store_list(cfg->workspace_root, ids, 200);
cJSON *root = cJSON_CreateObject();
if (!root) {
free(ids);
return cst_http_send_err(c, 500, "out of memory");
}
char cur[128] = "";
cst_agent_store_get_current(cfg->workspace_root, cur, sizeof(cur));
cJSON_AddStringToObject(root, "current", cur);
cJSON *arr = cJSON_AddArrayToObject(root, "sessions");
for (int i = 0; arr && i < n; i++) {
cst_session_info_t in;
if (cst_agent_store_info(cfg->workspace_root, ids[i], &in) != 0)
continue;
cJSON *o = cJSON_CreateObject();
if (!o)
continue;
cJSON_AddStringToObject(o, "id", in.id);
cJSON_AddStringToObject(o, "title",
in.title[0] ? in.title : in.first_user);
cJSON_AddNumberToObject(o, "messages", in.messages);
cJSON_AddNumberToObject(o, "mtime", (double)in.mtime);
cJSON_AddItemToArray(arr, o);
}
free(ids);
char *json = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
int rc = cst_http_send_json(c, 200, json);
free(json);
return rc;
}
static int h_session_new_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
char id[128] = "";
if (cst_agent_new_session(w->agent, id, sizeof(id)) != 0)
return cst_http_send_err(c, 500, "cannot start a session");
wui_emit_str(w, "session", "id", id);
wui_emit_state(w);
cJSON *o = cJSON_CreateObject();
if (!o)
return cst_http_send_err(c, 500, "out of memory");
cJSON_AddStringToObject(o, "id", id);
char *json = cJSON_PrintUnformatted(o);
cJSON_Delete(o);
int rc = cst_http_send_json(c, 200, json);
free(json);
return rc;
}
/* POST /api/workspace {"path":"/abs/dir"} - re-root the agent. The session
* store is per directory, so this always lands in a fresh session there. */
static int h_workspace_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_http_req_t *req = cst_http_request(c);
cJSON *j = req->body_len ? cJSON_Parse(req->body) : NULL;
const cJSON *p = j ? cJSON_GetObjectItem(j, "path") : NULL;
if (!cJSON_IsString(p) || !p->valuestring[0]) {
cJSON_Delete(j);
return cst_http_send_err(c, 400, "path is required");
}
char err[256] = "";
int rc = cst_agent_set_workspace(w->agent, p->valuestring, err, sizeof(err));
cJSON_Delete(j);
if (rc != 0)
return cst_http_send_err(c, 400, err[0] ? err : "cannot switch workspace");
/* MCP servers, plugins and the task tool are bound to a workspace */
cst_extensions_unload();
cst_extensions_load(cst_agent_config(w->agent), NULL, 0);
char id[128] = "";
if (cst_agent_new_session(w->agent, id, sizeof(id)) != 0)
return cst_http_send_err(c, 500, "cannot start a session there");
wui_emit_str(w, "session", "id", id);
wui_emit_state(w);
cJSON *o = cJSON_CreateObject();
if (!o)
return cst_http_send_err(c, 500, "out of memory");
cJSON_AddStringToObject(o, "id", id);
char *json = cJSON_PrintUnformatted(o);
cJSON_Delete(o);
rc = cst_http_send_json(c, 200, json);
free(json);
return rc;
}
static int h_session_switch_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_http_req_t *req = cst_http_request(c);
cJSON *j = req->body_len ? cJSON_Parse(req->body) : NULL;
const cJSON *id = j ? cJSON_GetObjectItem(j, "id") : NULL;
int rc = -2;
if (cJSON_IsString(id))
rc = cst_agent_switch_session(w->agent, id->valuestring);
cJSON_Delete(j);
if (rc != 0)
return cst_http_send_err(c, 404, "no such session");
wui_emit_state(w);
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
"{\"ok\":true}");
}
/* Like wui_claim but a running turn is fine: for work that reads the session
* object (a turn only appends to it, under its lock) or the store. */
static int wui_claim_read(wui_t *w) {
pthread_mutex_lock(&w->mu);
int ok = !w->op_busy;
if (ok)
w->op_busy = 1;
pthread_mutex_unlock(&w->mu);
return ok ? 0 : -1;
}
/* POST /api/sessions/rename {"id"?: "...", "title": "..."}; "" clears it. */
static int h_session_rename(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cJSON *j = wui_body(c);
const cJSON *id = j ? cJSON_GetObjectItem(j, "id") : NULL;
const cJSON *title = j ? cJSON_GetObjectItem(j, "title") : NULL;
if (!cJSON_IsString(title) || (id && !cJSON_IsString(id))) {
cJSON_Delete(j);
return cst_http_send_err(c, 400, "需要 {\"title\": \"…\", \"id\": 可选}");
}
if (wui_claim_read(w) != 0) {
cJSON_Delete(j);
return cst_http_send_err(c, 409, "another change is running");
}
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
char sid[128], t[121];
if (id && id->valuestring[0])
snprintf(sid, sizeof(sid), "%s", id->valuestring);
else
cst_agent_session_id(w->agent, sid, sizeof(sid));
size_t tn = strlen(title->valuestring);
if (tn > sizeof(t) - 1)
tn = cst_utf8_floor(title->valuestring, sizeof(t) - 1);
memcpy(t, title->valuestring, tn);
t[tn] = 0;
cJSON_Delete(j);
cst_session_info_t in;
int rc = cst_agent_store_info(cfg->workspace_root, sid, &in) != 0 ? 404
: cst_agent_store_title_set(cfg->workspace_root, sid, t) != 0 ? 500
: 200;
wui_release(w);
if (rc == 404)
return cst_http_send_err(c, 404, "no such session");
if (rc == 500)
return cst_http_send_err(c, 500, "cannot set the title");
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
"{\"ok\":true}");
}
/* POST /api/sessions/delete {"id": "..."}. Deleting the open session first
* moves to a new one. */
static int h_session_delete_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cJSON *j = wui_body(c);
const cJSON *id = j ? cJSON_GetObjectItem(j, "id") : NULL;
if (!cJSON_IsString(id) || !id->valuestring[0]) {
cJSON_Delete(j);
return cst_http_send_err(c, 400, "需要 {\"id\": \"…\"}");
}
char victim[128];
snprintf(victim, sizeof(victim), "%s", id->valuestring);
cJSON_Delete(j);
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
cst_session_info_t in;
if (cst_agent_store_info(cfg->workspace_root, victim, &in) != 0)
return cst_http_send_err(c, 404, "no such session");
char cur[128];
cst_agent_session_id(w->agent, cur, sizeof(cur));
int was_current = strcmp(cur, victim) == 0;
if (was_current) {
if (cst_agent_new_session(w->agent, cur, sizeof(cur)) != 0)
return cst_http_send_err(c, 500, "cannot start a session");
wui_emit_str(w, "session", "id", cur);
wui_emit_state(w);
}
if (cst_agent_store_delete(cfg->workspace_root, victim) != 0)
return cst_http_send_err(c, 500, "cannot delete the session");
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddBoolToObject(o, "ok", 1);
cJSON_AddStringToObject(o, "current", cur);
}
return wui_send_obj(c, 200, o);
}
/* POST /api/retry {"text"?: "..."}: drop the last prompt and everything after
* it, then run it again, or text in its place. File changes are kept. */
static int h_retry(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cJSON *j = wui_body(c);
const cJSON *text = j ? cJSON_GetObjectItem(j, "text") : NULL;
if (text && (!cJSON_IsString(text) || !text->valuestring[0])) {
cJSON_Delete(j);
return cst_http_send_err(c, 400, "text 不能为空");
}
if (wui_claim(w) != 0) {
cJSON_Delete(j);
return cst_http_send_err(c, 409, "busy: wait for the running turn");
}
char *prompt = cst_agent_rewind_last_turn(w->agent);
if (!prompt) {
wui_release(w);
cJSON_Delete(j);
return cst_http_send_err(c, 409, "nothing to retry");
}
const char *use = text ? text->valuestring : prompt;
char err[256] = "";
int rc = wui_start_turn(w, use, 1, err, sizeof(err));
cJSON *o = rc == 0 ? cJSON_CreateObject() : NULL;
if (o) {
cJSON_AddBoolToObject(o, "ok", 1);
cJSON_AddStringToObject(o, "text", use);
}
free(prompt);
cJSON_Delete(j);
if (rc != 0) {
cJSON_Delete(o);
return cst_http_send_err(c, 500, err);
}
return wui_send_obj(c, 202, o);
}
/* GET /api/export: the open session as Markdown. */
static int h_export(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
if (wui_claim_read(w) != 0)
return cst_http_send_err(c, 409, "another change is running");
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
char sid[128];
cst_agent_session_id(w->agent, sid, sizeof(sid));
cst_session_info_t in;
const char *title = sid;
if (cst_agent_store_info(cfg->workspace_root, sid, &in) == 0)
title = in.title[0] ? in.title : in.first_user[0] ? in.first_user : sid;
char *md = cst_session_export_md(cst_agent_session(w->agent), title, sid,
cfg->model);
wui_release(w);
if (!md)
return cst_http_send_err(c, 500, "out of memory");
int rc = cst_http_send_str(c, 200, "text/markdown; charset=utf-8", md);
free(md);
return rc;
}
static int h_config(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_config_t *cfg = cst_agent_config_mut(w->agent);
const cst_http_req_t *req = cst_http_request(c);
cJSON *j = req->body_len ? cJSON_Parse(req->body) : NULL;
if (!j || !cfg) {
cJSON_Delete(j);
return cst_http_send_err(c, 400, "expected a JSON object");
}
/* everything is checked before anything changes */
const cJSON *model = cJSON_GetObjectItem(j, "model");
const cJSON *effort = cJSON_GetObjectItem(j, "effort");
const cJSON *approval = cJSON_GetObjectItem(j, "approval");
const cJSON *plan = cJSON_GetObjectItem(j, "plan_mode");
const cJSON *think = cJSON_GetObjectItem(j, "show_thinking");
const char *bad = NULL;
if (cJSON_GetObjectItem(j, "provider"))
bad = "切换提供商请用 POST /api/provider";
else if (model && !wui_model_ok(model))
bad = "模型名无效(1-127 个可见字符)";
else if (effort && (!cJSON_IsString(effort) ||
!cst_effort_valid(effort->valuestring)))
bad = "推理强度只能是 low / medium / high / max";
int a = 0;
if (!bad && approval && (!cJSON_IsString(approval) ||
cst_approval_parse(approval->valuestring, &a) != 0))
bad = "审批模式只能是 ask / auto / yolo";
if (!bad && plan && !cJSON_IsBool(plan))
bad = "plan_mode 必须是 true 或 false";
if (!bad && think && !cJSON_IsBool(think))
bad = "show_thinking 必须是 true 或 false";
if (bad) {
cJSON_Delete(j);
return cst_http_send_err(c, 400, bad);
}
/* the strings are read by the turn as it builds each request; approval
* and plan mode may change mid-turn (say, to yolo at a pending gate) */
int claimed = model || effort;
if (claimed && wui_claim(w) != 0) {
cJSON_Delete(j);
return cst_http_send_err(c, 409, "正在运行,等这一轮结束再改模型");
}
if (model)
snprintf(cfg->model, sizeof(cfg->model), "%s", model->valuestring);
if (effort)
snprintf(cfg->effort, sizeof(cfg->effort), "%s", effort->valuestring);
if (claimed)
cst_settings_remember(cfg);
if (approval) {
cfg->approval = a;
cfg->auto_approve = a == CST_APPROVAL_YOLO;
if (a != CST_APPROVAL_YOLO) /* YOLO lasts for this run only */
cst_settings_remember_approval(a);
}
if (plan)
cfg->plan_mode = cJSON_IsTrue(plan) ? 1 : 0;
if (think) {
pthread_mutex_lock(&w->mu);
w->show_thinking = cJSON_IsTrue(think) ? 1 : 0;
pthread_mutex_unlock(&w->mu);
cst_settings_remember_thinking(cJSON_IsTrue(think));
}
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddBoolToObject(o, "ok", 1);
if (effort && !cst_effort_wire(cfg->provider, cfg->model, cfg->effort))
cJSON_AddStringToObject(o, "warning", "当前模型不接受推理强度参数:已保存,"
"换到支持的模型时生效");
}
cJSON_Delete(j);
if (claimed)
wui_release(w);
wui_emit_state(w);
return wui_send_obj(c, 200, o);
}
/* GET /api/diff[?path=rel]: git status and the working-tree diff. git runs
* without a shell, external diff drivers or textconv, for at most 10 s. */
#define WUI_DIFF_CAP (512u * 1024u)
static int h_diff(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
char path[1024] = "";
cst_http_query(cst_http_request(c), "path", path, sizeof(path));
char ws[sizeof(((cst_agent_config_t *)0)->workspace_root)];
pthread_mutex_lock(&w->mu);
int ok = !w->op_busy;
if (ok)
snprintf(ws, sizeof(ws), "%s", cst_agent_config(w->agent)->workspace_root);
pthread_mutex_unlock(&w->mu);
if (!ok)
return cst_http_send_err(c, 409, "another change is running");
os_set_run_timeout(10);
os_run_result_t st, df;
memset(&st, 0, sizeof(st));
memset(&df, 0, sizeof(df));
const char *sv[] = {"git", "status", "--short", "--untracked-files=all", NULL};
int git = os_run_capture(ws, sv, 64 * 1024, &st) == 0 && st.exit_code == 0;
const char *dv[] = {"git", "--no-pager", "diff", "--no-color",
"--no-ext-diff", "--no-textconv", "--",
path[0] ? path : NULL, NULL};
if (git)
os_run_capture(ws, dv, WUI_DIFF_CAP, &df);
os_set_run_timeout(0);
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddBoolToObject(o, "git", git);
cJSON_AddStringToObject(o, "path", path);
if (git) {
cJSON *files = cJSON_AddArrayToObject(o, "files");
const char *s = st.out ? st.out : "";
while (files && *s) {
size_t n = strcspn(s, "\n");
if (n > 3) {
char code[3] = {s[0], s[1], 0}, name[1024];
snprintf(name, sizeof(name), "%.*s", (int)(n - 3), s + 3);
cJSON *f = cJSON_CreateObject();
if (f) {
cJSON_AddStringToObject(f, "status", code);
cJSON_AddStringToObject(f, "path", name);
cJSON_AddItemToArray(files, f);
}
}
s += n + (s[n] == '\n');
}
size_t dl = df.out ? cst_utf8_floor(df.out, df.out_len) : 0;
char *d = malloc(dl + 1);
if (d) {
memcpy(d, df.out ? df.out : "", dl);
d[dl] = 0;
cJSON_AddStringToObject(o, "diff", d);
free(d);
}
cJSON_AddBoolToObject(o, "truncated", df.out_len >= WUI_DIFF_CAP);
if (df.exit_code != 0 && df.exit_code != 1)
cJSON_AddNumberToObject(o, "exit", df.exit_code);
}
}
os_run_result_free(&st);
os_run_result_free(&df);
return wui_send_obj(c, 200, o);
}
static const char *const wui_perm_names[] = {"read", "write", "shell",
"net", "ask", "meta"};
/* GET /api/settings: what POST /api/settings can change, plus the sandbox. */
static int h_settings_get(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_config_t *cfg = malloc(sizeof(*cfg));
if (!cfg)
return cst_http_send_err(c, 500, "out of memory");
/* held to the end: an extensions reload frees the tools listed below */
if (wui_claim_read(w) != 0) {
free(cfg);
return cst_http_send_err(c, 409, "正在切换设置,稍后再试");
}
*cfg = *cst_agent_config(w->agent);
memset(cfg->api_key, 0, sizeof(cfg->api_key));
cJSON *o = cJSON_CreateObject();
if (!o) {
wui_release(w);
free(cfg);
return cst_http_send_err(c, 500, "out of memory");
}
cJSON_AddStringToObject(o, "protocol", cst_protocol_name(cfg->protocol));
cJSON *ps = cJSON_AddArrayToObject(o, "protocols");
for (int p = CST_PROTO_NATIVE_EOS; ps && p <= CST_PROTO_XML; p++)
cJSON_AddItemToArray(ps, cJSON_CreateString(cst_protocol_name(p)));
cJSON_AddNumberToObject(o, "max_steps", cfg->max_steps);
cJSON_AddStringToObject(o, "tool_profile",
cst_tool_profile_name(cfg->tool_profile));
cJSON *pr = cJSON_AddArrayToObject(o, "profiles");
for (int p = CST_TOOLS_DEFAULT; pr && p <= CST_TOOLS_MINIMAL; p++)
cJSON_AddItemToArray(pr, cJSON_CreateString(cst_tool_profile_name(p)));
cJSON_AddStringToObject(o, "mode", cst_mode_name(cfg->mode));
cJSON *md = cJSON_AddArrayToObject(o, "modes");
if (md) {
cJSON_AddItemToArray(md, cJSON_CreateString("coding"));
cJSON_AddItemToArray(md, cJSON_CreateString("general"));
}
cJSON *cf = cJSON_AddObjectToObject(o, "confirm");
if (cf) {
cJSON_AddBoolToObject(cf, "shell", cfg->confirm_shell);
cJSON_AddBoolToObject(cf, "write", cfg->confirm_write);
cJSON_AddBoolToObject(cf, "net", cfg->confirm_net);
}
cJSON_AddStringToObject(o, "approval", cst_approval_name(cfg->approval));
cJSON *tools = cJSON_AddArrayToObject(o, "tools");
for (int i = 0; tools && i < cst_tool_count(); i++) {
const cst_tool_def_t *d = cst_tool_at(i);
cJSON *t = d ? cJSON_CreateObject() : NULL;
if (!t)
continue;
const char *desc = d->desc ? d->desc : "";
char first[160];
size_t dl = strcspn(desc, ".\n");
dl = cst_utf8_floor(desc, dl < sizeof(first) - 1 ? dl : sizeof(first) - 1);
memcpy(first, desc, dl);
first[dl] = 0;
cJSON_AddStringToObject(t, "name", d->name);
cJSON_AddStringToObject(t, "perm", (int)d->perm < 6 ? wui_perm_names[d->perm]
: "?");
cJSON_AddStringToObject(t, "perm_name", cst_tool_perm_name(d));
cJSON_AddBoolToObject(t, "on", cst_tool_active(cfg, d));
cJSON_AddBoolToObject(t, "off", cst_tool_disabled(cfg, d->name));
cJSON_AddStringToObject(t, "desc", first);
if (d->platforms) {
cJSON *pl = cJSON_AddArrayToObject(t, "platforms");
for (int k = 0; pl && d->platforms[k]; k++)
cJSON_AddItemToArray(pl, cJSON_CreateString(d->platforms[k]));
}
char why[160] = "";
if (cst_tool_available(d, why, sizeof(why)) != 0) {
cJSON_AddBoolToObject(t, "available", 0);
cJSON_AddStringToObject(t, "why", why);
} else {
cJSON_AddBoolToObject(t, "available", 1);
}
if (d->params[0].name) {
cJSON *pa = cJSON_AddArrayToObject(t, "params");
for (int k = 0; pa && k < CST_TOOL_MAX_PARAMS && d->params[k].name; k++) {
cJSON *p = cJSON_CreateObject();
cJSON_AddStringToObject(p, "name", d->params[k].name);
cJSON_AddStringToObject(p, "type", d->params[k].type);
cJSON_AddBoolToObject(p, "required", d->params[k].required);
cJSON_AddItemToArray(pa, p);
}
}
if (d->owner)
cJSON_AddStringToObject(t, "owner", d->owner);
cJSON_AddItemToArray(tools, t);
}
wui_release(w);
int sk = cst_sandbox_probe();
cJSON *sb = cJSON_AddObjectToObject(o, "sandbox");
if (sb) {
cJSON_AddStringToObject(sb, "kind", cst_sandbox_name(sk));
cJSON_AddBoolToObject(sb, "active", sk != CST_SANDBOX_NONE);
char *l = cst_sandbox_labels();
if (l)
cJSON_AddStringToObject(sb, "labels", l);
free(l);
}
free(cfg);
return wui_send_obj(c, 200, o);
}
/* POST /api/settings {protocol?, max_steps?, tool_profile?,
* tools?: {name: on}, confirm?: {shell?, write?, net?}}. All checked before
* any of it applies; this run only, like the TUI commands. */
static int h_settings_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_config_t *cfg = cst_agent_config_mut(w->agent);
cJSON *j = wui_body(c);
if (!cJSON_IsObject(j)) {
cJSON_Delete(j);
return cst_http_send_err(c, 400, "expected a JSON object");
}
const cJSON *proto = cJSON_GetObjectItem(j, "protocol");
const cJSON *steps = cJSON_GetObjectItem(j, "max_steps");
const cJSON *prof = cJSON_GetObjectItem(j, "tool_profile");
const cJSON *mode = cJSON_GetObjectItem(j, "mode");
const cJSON *tools = cJSON_GetObjectItem(j, "tools");
const cJSON *conf = cJSON_GetObjectItem(j, "confirm");
cst_protocol_t p = cfg->protocol;
int profile = cfg->tool_profile;
char bad[200] = "";
if (proto && (!cJSON_IsString(proto) ||
cst_protocol_parse(proto->valuestring, &p) != 0))
snprintf(bad, sizeof(bad), "未知的工具协议");
else if (steps && (!cJSON_IsNumber(steps) || steps->valuedouble < 1 ||
steps->valuedouble > 1000))
snprintf(bad, sizeof(bad), "max_steps 要在 1 到 1000 之间");
else if (prof && (!cJSON_IsString(prof) ||
cst_tool_profile_parse(prof->valuestring, &profile) != 0))
snprintf(bad, sizeof(bad), "未知的工具集(default 或 minimal)");
else if (mode && (!cJSON_IsString(mode) ||
!cst_mode_valid(mode->valuestring)))
snprintf(bad, sizeof(bad), "工作模式只能是 coding / general");
else if (tools && !cJSON_IsObject(tools))
snprintf(bad, sizeof(bad), "tools 要是 {\"名字\": true/false}");
else if (conf && !cJSON_IsObject(conf))
snprintf(bad, sizeof(bad), "confirm 要是 {shell, write, net}");
for (const cJSON *t = tools ? tools->child : NULL; !bad[0] && t; t = t->next)
if (!cJSON_IsBool(t) || cst_tool_index(t->string) < 0)
snprintf(bad, sizeof(bad), "未知的工具或取值:%.80s", t->string);
for (const cJSON *t = conf ? conf->child : NULL; !bad[0] && t; t = t->next)
if (!cJSON_IsBool(t) || (strcmp(t->string, "shell") != 0 &&
strcmp(t->string, "write") != 0 &&
strcmp(t->string, "net") != 0))
snprintf(bad, sizeof(bad), "未知的确认项:%.80s", t->string);
if (bad[0]) {
cJSON_Delete(j);
return cst_http_send_err(c, 400, bad);
}
cfg->protocol = p;
cfg->tool_profile = profile;
if (mode) {
snprintf(cfg->mode, sizeof(cfg->mode), "%s",
cst_mode_name(mode->valuestring));
cst_settings_remember_mode(cfg->mode);
}
if (steps)
cfg->max_steps = (int)steps->valuedouble;
for (const cJSON *t = tools ? tools->child : NULL; t; t = t->next)
cst_tool_set_disabled(cfg, t->string, cJSON_IsFalse(t));
for (const cJSON *t = conf ? conf->child : NULL; t; t = t->next) {
int on = cJSON_IsTrue(t);
if (strcmp(t->string, "shell") == 0)
cfg->confirm_shell = on;
else if (strcmp(t->string, "write") == 0)
cfg->confirm_write = on;
else
cfg->confirm_net = on;
}
cJSON_Delete(j);
wui_emit_state(w);
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
"{\"ok\":true}");
}
/* GET /api/permissions: the saved always-allow rules, read from disk (the
* agent's copy is the turn's to change). */
static int h_perms_get(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
char ws[sizeof(((cst_agent_config_t *)0)->workspace_root)];
pthread_mutex_lock(&w->mu);
int ok = !w->op_busy;
if (ok)
snprintf(ws, sizeof(ws), "%s", cst_agent_config(w->agent)->workspace_root);
pthread_mutex_unlock(&w->mu);
if (!ok)
return cst_http_send_err(c, 409, "another change is running");
cst_perms_t *p = cst_perms_open(ws);
char *d = p ? cst_perms_describe(p) : NULL;
cst_perms_close(p);
char path[sizeof(ws) + 32], *raw = NULL;
size_t rn = 0;
snprintf(path, sizeof(path), "%s/.agent/permissions.json", ws);
cJSON *saved = read_file_all(path, &raw, &rn) == 0
? cJSON_ParseWithLength(raw, rn)
: NULL;
free(raw);
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddStringToObject(o, "rules", d ? d : "");
cJSON_AddStringToObject(o, "file", ".agent/permissions.json");
static const char *const keys[] = {"tools", "shell_prefixes"};
for (int k = 0; k < 2; k++) {
cJSON *arr = cJSON_AddArrayToObject(o, keys[k]);
const cJSON *it;
cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(saved, keys[k]))
if (arr && cJSON_IsString(it))
cJSON_AddItemToArray(arr, cJSON_CreateString(it->valuestring));
}
}
cJSON_Delete(saved);
free(d);
return wui_send_obj(c, 200, o);
}
/* DELETE /api/permissions: forget every saved rule. */
static int h_perms_reset_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
if (cst_perms_reset(cst_agent_perms(w->agent)) != 0)
return cst_http_send_err(c, 500, "cannot reset the rules");
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
"{\"ok\":true}");
}
/* GET /api/providers: the presets, whether each has a key and from where.
* Never the key itself. */
static int h_providers(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_config_t *cfg = malloc(sizeof(*cfg));
if (!cfg)
return cst_http_send_err(c, 500, "out of memory");
if (wui_cfg_snapshot(w, cfg) != 0) {
free(cfg);
return cst_http_send_err(c, 409, "正在切换设置,稍后再试");
}
cJSON *o = cJSON_CreateObject();
if (!o) {
free(cfg);
return cst_http_send_err(c, 500, "out of memory");
}
cJSON_AddStringToObject(o, "current", cfg->provider);
cJSON *arr = cJSON_AddArrayToObject(o, "providers");
int n = cst_provider_count();
for (int i = 0; arr && i < n; i++) {
const cst_provider_t *p = cst_provider_at(i);
cJSON *e = cJSON_CreateObject();
if (!p || !e) {
cJSON_Delete(e);
continue;
}
int cur = strcmp(p->name, cfg->provider) == 0;
cJSON_AddStringToObject(e, "name", p->name);
cJSON_AddStringToObject(e, "api_base", cur ? cfg->api_base : p->api_base);
cJSON_AddStringToObject(e, "model", cur ? cfg->model
: (p->model ? p->model : ""));
char from[64];
cJSON_AddBoolToObject(e, "has_key",
key_source(cfg, p->name, from, sizeof(from)));
cJSON_AddStringToObject(e, "key_from", from);
cJSON_AddStringToObject(e, "key_env", p->key_env ? p->key_env : "");
cJSON_AddStringToObject(e, "source", cst_provider_source(p));
cJSON_AddBoolToObject(e, "current", cur);
cJSON_AddItemToArray(arr, e);
}
memset(cfg->api_key, 0, sizeof(cfg->api_key));
free(cfg);
return wui_send_obj(c, 200, o);
}
/* GET /api/models[?refresh=1]: the picker list for the current provider.
* A rejected key is a 401 that still carries the built-in names. */
static int h_models(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_http_req_t *req = cst_http_request(c);
char rf[8] = "";
cst_http_query(req, "refresh", rf, sizeof(rf));
cst_agent_config_t *snap = malloc(sizeof(*snap));
int max = CST_PS_MAX_MODELS + 32;
cst_ps_model_t *list = calloc((size_t)max, sizeof(*list));
if (!snap || !list) {
free(snap);
free(list);
return cst_http_send_err(c, 500, "out of memory");
}
if (wui_cfg_snapshot(w, snap) != 0) {
free(snap);
free(list);
return cst_http_send_err(c, 409, "正在切换设置,稍后再试");
}
int api_rc = -1;
char err[256] = "";
int n = cst_ps_models(snap, rf[0] == '1', list, max, &api_rc, err,
sizeof(err));
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddStringToObject(o, "provider", snap->provider);
cJSON_AddStringToObject(o, "current", snap->model);
cJSON_AddStringToObject(o, "api_base", snap->api_base);
cJSON_AddBoolToObject(o, "key_set", snap->api_key[0] != 0);
cJSON_AddNumberToObject(o, "api_count", api_rc >= 0 ? api_rc : -1);
if (api_rc == CST_MODELS_AUTH) {
char msg[400];
snprintf(msg, sizeof(msg), "%s 拒绝了当前 API key(%.200s),请换一个",
snap->provider, err);
cJSON_AddStringToObject(o, "error", msg);
cJSON_AddBoolToObject(o, "auth", 1);
} else if (api_rc < 0 && err[0]) {
char msg[400];
snprintf(msg, sizeof(msg), "拉取模型列表失败:%.300s", err);
cJSON_AddStringToObject(o, "api_error", msg);
}
cJSON *arr = cJSON_AddArrayToObject(o, "models");
for (int i = 0; arr && i < n; i++) {
cJSON *e = cJSON_CreateObject();
if (!e)
continue;
cJSON_AddStringToObject(e, "id", list[i].id);
cJSON_AddBoolToObject(e, "api", list[i].api);
model_info_json(e, snap->provider, list[i].id);
cJSON_AddItemToArray(arr, e);
}
}
memset(snap->api_key, 0, sizeof(snap->api_key));
free(snap);
free(list);
return wui_send_obj(c, api_rc == CST_MODELS_AUTH ? 401 : 200, o);
}
/* The answer to a key check / provider switch, in the browser's words. */
static int ps_reply(wui_t *w, cst_http_conn_t *c, int r, const char *who,
const char *err) {
char msg[512];
if (r == CST_PS_UNKNOWN) {
snprintf(msg, sizeof(msg), "没有这个提供商:%.64s", who);
return cst_http_send_err(c, 404, msg);
}
if (r == CST_MODELS_AUTH) {
snprintf(msg, sizeof(msg), "%.64s 拒绝了这个 API key(%.300s)", who, err);
return cst_http_send_err(c, 401, msg);
}
if (r < 0) {
cJSON *o = cJSON_CreateObject();
if (o) {
snprintf(msg, sizeof(msg), "%.64s 需要 API key", who);
cJSON_AddStringToObject(o, "error", msg);
cJSON_AddBoolToObject(o, "need_key", 1);
}
return wui_send_obj(c, 400, o);
}
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddBoolToObject(o, "ok", 1);
cJSON_AddStringToObject(o, "provider", cfg->provider);
cJSON_AddStringToObject(o, "model", cfg->model);
msg[0] = 0;
if (r & CST_PS_UNSAVED)
snprintf(msg, sizeof(msg), "key 没能写进 credentials 文件,只在本次运行有效");
else if (r & CST_PS_UNVERIFIED)
snprintf(msg, sizeof(msg), "没能验证 key(%.300s),已先保存", err);
if (msg[0])
cJSON_AddStringToObject(o, "warning", msg);
}
wui_emit_state(w);
return wui_send_obj(c, 200, o);
}
/* Wipe a secret out of a parsed body before it is freed. */
static void wipe_str(const cJSON *s) {
if (cJSON_IsString(s) && s->valuestring)
memset(s->valuestring, 0, strlen(s->valuestring));
}
/* POST /api/provider {"name": "...", "key": "..."?} */
static int h_provider_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_config_t *cfg = cst_agent_config_mut(w->agent);
cJSON *j = wui_body(c);
const cJSON *name = j ? cJSON_GetObjectItem(j, "name") : NULL;
const cJSON *key = j ? cJSON_GetObjectItem(j, "key") : NULL;
if (!cJSON_IsString(name) || (key && !cJSON_IsString(key))) {
wipe_str(key);
cJSON_Delete(j);
return cst_http_send_err(c, 400, "需要 {\"name\": \"提供商\", \"key\": 可选}");
}
char who[64], err[256] = "";
snprintf(who, sizeof(who), "%s", name->valuestring);
int r = cst_ps_switch_provider(cfg, who, key ? key->valuestring : NULL, err,
sizeof(err));
wipe_str(key);
cJSON_Delete(j);
if (r >= 0) {
char line[256];
snprintf(line, sizeof(line), "提供商 → %s · 模型 %s", cfg->provider,
cfg->model[0] ? cfg->model : "(未选)");
wui_emit_str(w, "status", "message", line);
}
return ps_reply(w, c, r, who, err);
}
/* POST /api/key {"key": "..."}: replace the current provider's key. */
static int h_key_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_config_t *cfg = cst_agent_config_mut(w->agent);
cJSON *j = wui_body(c);
const cJSON *key = j ? cJSON_GetObjectItem(j, "key") : NULL;
if (!cJSON_IsString(key) || !key->valuestring[0]) {
wipe_str(key);
cJSON_Delete(j);
return cst_http_send_err(c, 400, "需要 {\"key\": \"...\"}");
}
char err[256] = "";
int r = cst_ps_set_key(cfg, key->valuestring, err, sizeof(err));
wipe_str(key);
cJSON_Delete(j);
return ps_reply(w, c, r, cfg->provider, err);
}
static int h_undo_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
char report[2048] = "";
if (cst_agent_undo(w->agent, report, sizeof(report)) != 0)
return cst_http_send_err(c, 409, "nothing to undo");
wui_emit_str(w, "status", "message", report);
wui_emit_state(w);
return cst_http_send_str(c, 200, "text/plain; charset=utf-8", report);
}
static int h_compact_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
char err[256] = "";
if (cst_agent_compact(w->agent, err, sizeof(err)) != 0)
return cst_http_send_err(c, 500, err);
wui_emit_state(w);
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
"{\"ok\":true}");
}
static int h_attach_upload_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_http_req_t *req = cst_http_request(c);
char name[256] = "pasted.png";
cst_http_query(req, "name", name, sizeof(name));
if (req->body_len == 0)
return cst_http_send_err(c, 400, "empty body");
char err[256] = "";
if (cst_agent_attach_bytes(w->agent, name, req->body, req->body_len, err,
sizeof(err)) != 0)
return cst_http_send_err(c, 400, err[0] ? err : "cannot attach the image");
wui_emit_state(w);
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
"{\"ok\":true}");
}
static int h_attach_clear_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_pending_clear(w->agent);
wui_emit_state(w);
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
"{\"ok\":true}");
}
/* --- /init, /skill and extensions ------------------------------------------ */
static int wui_turn_reply(cst_http_conn_t *c, const char *text) {
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddBoolToObject(o, "ok", 1);
cJSON_AddStringToObject(o, "text", text);
}
return wui_send_obj(c, 202, o);
}
/* POST /api/init: a turn that has the agent write AGENTS.md. */
static int h_init(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
char err[256] = "";
if (wui_start_turn(w, cst_init_prompt(), 0, err, sizeof(err)) != 0)
return cst_http_send_err(c, 409, err);
return wui_turn_reply(c, cst_init_prompt());
}
/* POST /api/skill {"name": "...", "task"?: "..."}: a turn with the skill's
* instructions inlined before the task. */
static int h_skill(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cJSON *j = wui_body(c);
const cJSON *name = j ? cJSON_GetObjectItem(j, "name") : NULL;
const cJSON *task = j ? cJSON_GetObjectItem(j, "task") : NULL;
if (!cJSON_IsString(name) || !name->valuestring[0] ||
strlen(name->valuestring) > 63 || (task && !cJSON_IsString(task))) {
cJSON_Delete(j);
return cst_http_send_err(c, 400, "需要 {\"name\": \"skill 名\", \"task\": 可选}");
}
if (wui_claim(w) != 0) {
cJSON_Delete(j);
return cst_http_send_err(c, 409, "busy: wait for the running turn");
}
char *p = cst_skill_prompt(cst_agent_config(w->agent)->workspace_root,
name->valuestring, task ? task->valuestring : "");
cJSON_Delete(j);
if (!p) {
wui_release(w);
return cst_http_send_err(c, 404, "no such skill");
}
char err[256] = "";
int rc = wui_start_turn(w, p, 1, err, sizeof(err));
rc = rc == 0 ? wui_turn_reply(c, p) : cst_http_send_err(c, 500, err);
free(p);
return rc;
}
static const char *wui_mcp_state(int st) {
switch (st) {
case CST_MCP_STARTING: return "starting";
case CST_MCP_READY: return "ready";
case CST_MCP_FAILED: return "failed";
case CST_MCP_EXITED: return "exited";
default: return "off";
}
}
#define WUI_RUNS_MAX 64
/* GET /api/extensions: MCP servers, plugins, skills, sub-agent roles and the
* open session's sub-agent runs. */
static int h_extensions(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
if (wui_claim_read(w) != 0)
return cst_http_send_err(c, 409, "another change is running");
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
const char *ws = cfg->workspace_root;
char sid[128];
cst_agent_session_id(w->agent, sid, sizeof(sid));
cJSON *o = cJSON_CreateObject();
cst_mcp_info_t *mi = calloc(CST_MCP_MAX, sizeof(*mi));
cst_plugin_info_t *pi = calloc(CST_PLUGINS_MAX, sizeof(*pi));
cst_skill_t *sk = calloc(CST_SKILLS_MAX, sizeof(*sk));
cst_agent_role_t *ro = calloc(CST_ROLES_MAX, sizeof(*ro));
cst_subagent_rec_t *ru = calloc(WUI_RUNS_MAX, sizeof(*ru));
if (!o || !mi || !pi || !sk || !ro || !ru) {
wui_release(w);
cJSON_Delete(o);
free(mi);
free(pi);
free(sk);
free(ro);
free(ru);
return cst_http_send_err(c, 500, "out of memory");
}
cJSON_AddBoolToObject(o, "enabled", cfg->tool_profile != CST_TOOLS_MINIMAL);
cJSON_AddBoolToObject(o, "trusted", cst_settings_trusted(ws));
cJSON_AddStringToObject(o, "report", cst_extensions_report());
cJSON_AddBoolToObject(o, "task_tool", cst_tool_find("task") != NULL);
cJSON *a = cJSON_AddArrayToObject(o, "mcp");
int n = cst_mcp_info(mi, CST_MCP_MAX);
for (int i = 0; a && i < n; i++) {
cJSON *e = cJSON_CreateObject();
if (!e)
continue;
cJSON_AddStringToObject(e, "name", mi[i].name);
cJSON_AddStringToObject(e, "source", mi[i].source);
cJSON_AddStringToObject(e, "command", mi[i].command);
cJSON_AddStringToObject(e, "state", wui_mcp_state(mi[i].state));
cJSON_AddNumberToObject(e, "tools", mi[i].ntools);
cJSON_AddStringToObject(e, "error", mi[i].error);
cJSON_AddItemToArray(a, e);
}
a = cJSON_AddArrayToObject(o, "plugins");
n = cst_plugins_info(pi, CST_PLUGINS_MAX);
for (int i = 0; a && i < n; i++) {
cJSON *e = cJSON_CreateObject();
if (!e)
continue;
cJSON_AddStringToObject(e, "name", pi[i].name);
cJSON_AddStringToObject(e, "source", pi[i].source);
cJSON_AddStringToObject(e, "desc", pi[i].desc);
cJSON_AddNumberToObject(e, "tools", pi[i].ntools);
cJSON_AddNumberToObject(e, "mcp", pi[i].nmcp);
cJSON_AddBoolToObject(e, "skills", pi[i].nskills_root);
cJSON_AddStringToObject(e, "error", pi[i].error);
cJSON_AddItemToArray(a, e);
}
a = cJSON_AddArrayToObject(o, "skills");
n = cst_skills_list(ws, sk, CST_SKILLS_MAX);
for (int i = 0; a && i < n; i++) {
cJSON *e = cJSON_CreateObject();
if (!e)
continue;
cJSON_AddStringToObject(e, "name", sk[i].name);
cJSON_AddStringToObject(e, "source", sk[i].source);
cJSON_AddStringToObject(e, "desc", sk[i].desc);
cJSON_AddItemToArray(a, e);
}
a = cJSON_AddArrayToObject(o, "roles");
n = cst_roles_list(ws, ro, CST_ROLES_MAX);
for (int i = 0; a && i < n; i++) {
cJSON *e = cJSON_CreateObject();
if (!e)
continue;
cJSON_AddStringToObject(e, "name", ro[i].name);
cJSON_AddStringToObject(e, "source", ro[i].source);
cJSON_AddStringToObject(e, "desc", ro[i].desc);
cJSON_AddStringToObject(e, "model", ro[i].model);
cJSON_AddStringToObject(e, "profile", ro[i].profile);
cJSON_AddBoolToObject(e, "read_only", ro[i].read_only);
cJSON_AddItemToArray(a, e);
}
cst_roles_free(ro, n);
char *budget_json = cst_workers_budget_json(cfg);
cJSON *budget = budget_json ? cJSON_Parse(budget_json) : NULL;
free(budget_json);
if (budget) cJSON_AddItemToObject(o, "worker_budget", budget);
a = cJSON_AddArrayToObject(o, "runs");
n = cst_subagent_list(ws, sid, ru, WUI_RUNS_MAX);
for (int i = 0; a && i < n; i++) {
cJSON *e = cJSON_CreateObject();
if (!e)
continue;
cJSON_AddStringToObject(e, "file", ru[i].file);
cJSON_AddStringToObject(e, "role", ru[i].role);
cJSON_AddStringToObject(e, "desc", ru[i].desc);
cJSON_AddStringToObject(e, "status", ru[i].status);
cJSON_AddStringToObject(e, "provider", ru[i].provider);
cJSON_AddStringToObject(e, "model", ru[i].model);
cJSON_AddNumberToObject(e, "requests", ru[i].requests);
cJSON_AddNumberToObject(e, "max_steps", ru[i].max_steps);
cJSON_AddNumberToObject(e, "prompt_tokens", ru[i].prompt_tokens);
if (ru[i].cache_known) {
cJSON_AddNumberToObject(e, "cached_tokens", ru[i].cached_tokens);
cJSON_AddNumberToObject(e, "cache_write_tokens", ru[i].cache_write_tokens);
} else {
cJSON_AddNullToObject(e, "cached_tokens");
cJSON_AddNullToObject(e, "cache_write_tokens");
}
cJSON_AddNumberToObject(e, "output_tokens", ru[i].output_tokens);
if (ru[i].priced) {
cJSON_AddNumberToObject(e, "cost", ru[i].cost);
cJSON_AddStringToObject(e, "currency", ru[i].currency);
}
cJSON_AddNumberToObject(e, "steps", ru[i].steps);
cJSON_AddNumberToObject(e, "tools", ru[i].tools);
cJSON_AddNumberToObject(e, "ms", ru[i].ms);
cJSON_AddItemToArray(a, e);
}
wui_release(w);
free(mi);
free(pi);
free(sk);
free(ro);
free(ru);
return wui_send_obj(c, 200, o);
}
static int wui_ext_reply(wui_t *w, cst_http_conn_t *c) {
char rep[256];
cst_extensions_unload();
cst_extensions_load(cst_agent_config(w->agent), rep, sizeof(rep));
cJSON *o = cJSON_CreateObject();
if (o) {
cJSON_AddBoolToObject(o, "ok", 1);
cJSON_AddBoolToObject(
o, "trusted",
cst_settings_trusted(cst_agent_config(w->agent)->workspace_root));
cJSON_AddStringToObject(o, "report", rep);
}
return wui_send_obj(c, 200, o);
}
/* POST /api/extensions/reload: stop MCP servers and plugins, read them and
* the skills again. */
static int h_ext_reload_x(cst_http_conn_t *c, void *ud) {
return wui_ext_reply(ud, c);
}
static int h_workers_get(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
if (wui_claim_read(w) != 0)
return cst_http_send_err(c, 409, "another change is running");
char *s = cst_workers_config_get(cst_agent_config(w->agent)->workspace_root);
cJSON *j = s ? cJSON_Parse(s) : NULL;
free(s);
wui_release(w);
if (!j) return cst_http_send_err(c, 400, "invalid worker configuration");
return wui_send_obj(c, 200, j);
}
static int h_workers_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cJSON *j = wui_body(c);
char *s = j ? cJSON_PrintUnformatted(j) : NULL;
char err[256] = "expected JSON configuration";
int rc = s ? cst_workers_config_set(
cst_agent_config(w->agent)->workspace_root, s, err, sizeof(err)) : -1;
free(s); cJSON_Delete(j);
if (rc != 0) return cst_http_send_err(c, 400, err);
cst_workers_config_apply(cst_agent_config_mut(w->agent));
return cst_http_send_str(c, 200, "application/json", "{\"ok\":true}");
}
static int h_worker_cancel(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
if (wui_claim_read(w) != 0)
return cst_http_send_err(c, 409, "another change is running");
cJSON *j = wui_body(c);
const cJSON *id = cJSON_GetObjectItemCaseSensitive(j, "worker_id");
char sid[128];
cst_agent_session_id(w->agent, sid, sizeof(sid));
int rc = cJSON_IsString(id) ? cst_worker_cancel(
cst_agent_config(w->agent)->workspace_root, sid, id->valuestring) : -1;
cJSON_Delete(j);
wui_release(w);
if (rc != 0) return cst_http_send_err(c, 404, "running worker not found");
return cst_http_send_str(c, 200, "application/json", "{\"ok\":true}");
}
static int h_task_get(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
if (wui_claim_read(w)) return cst_http_send_err(c, 409, "session is changing");
char *s = cst_runtime_json(cst_agent_config(w->agent)->runtime);
cJSON *j = s ? cJSON_Parse(s) : NULL; free(s); wui_release(w);
return wui_send_obj(c, 200, j ? j : cJSON_CreateObject());
}
static int h_task_control(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
if (wui_claim_read(w)) return cst_http_send_err(c, 409, "session is changing");
cJSON *j = wui_body(c);
const cJSON *msg = cJSON_GetObjectItemCaseSensitive(j, "message");
const cJSON *ro = cJSON_GetObjectItemCaseSensitive(j, "read_only");
const cJSON *delegate = cJSON_GetObjectItemCaseSensitive(j, "delegation");
const cJSON *paused = cJSON_GetObjectItemCaseSensitive(j, "paused");
int valid = cJSON_IsObject(j) && (!msg || cJSON_IsString(msg)) && (!ro || cJSON_IsBool(ro)) && (!delegate || cJSON_IsBool(delegate)) && (!paused || cJSON_IsBool(paused));
int rc = -1;
if (valid) {
pthread_mutex_lock(&w->mu); int running = w->busy; pthread_mutex_unlock(&w->mu);
if (msg && msg->valuestring[0] && !running) {
if (ro || delegate || paused) cst_agent_control(w->agent, ro ? cJSON_IsTrue(ro) : -1, delegate ? cJSON_IsTrue(delegate) : -1, paused ? cJSON_IsTrue(paused) : -1);
char error[256]; rc = wui_start_turn(w, msg->valuestring, 1, error, sizeof(error));
} else if (msg && msg->valuestring[0]) rc = cst_agent_steer(w->agent, msg->valuestring, ro ? cJSON_IsTrue(ro) : -1, delegate ? cJSON_IsTrue(delegate) : -1);
else rc = cst_agent_control(w->agent, ro ? cJSON_IsTrue(ro) : -1, delegate ? cJSON_IsTrue(delegate) : -1, paused ? cJSON_IsTrue(paused) : -1);
}
cJSON_Delete(j); wui_release(w);
if (rc) return cst_http_send_err(c, 400, "expected message and/or boolean task controls");
return cst_http_send_str(c, 202, "application/json", "{\"ok\":true}");
}
static int h_worktree(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
if (wui_claim(w)) return cst_http_send_err(c, 409, "wait for the active task");
cJSON *j = wui_body(c);
const cJSON *path = cJSON_GetObjectItemCaseSensitive(j, "worktree");
int integrate = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(j, "integrate"));
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
int code = 1; char *text = NULL;
if (cJSON_IsString(path) && (!integrate || cst_runtime_check(cfg->runtime, cst_runtime_revision(cfg->runtime), 1, 0)) && !cst_runtime_jobs_pending(cfg->runtime))
text = cst_worktree_diff(cfg->workspace_root, path->valuestring, integrate, &code);
cJSON_Delete(j); wui_release(w);
cJSON *result = cJSON_CreateObject(); cJSON_AddBoolToObject(result, "ok", !code);
cJSON_AddStringToObject(result, "output", text ? text : "task is read-only, jobs are active, or worktree is missing"); free(text);
return wui_send_obj(c, code ? 409 : 200, result);
}
static int h_job_cancel(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
if (wui_claim_read(w)) return cst_http_send_err(c, 409, "session is changing");
cJSON *j = wui_body(c); const cJSON *id = cJSON_GetObjectItemCaseSensitive(j, "job_id");
const cJSON *force = cJSON_GetObjectItemCaseSensitive(j, "force");
int rc = cJSON_IsString(id) ? cst_job_cancel(cst_agent_config(w->agent)->runtime, id->valuestring, cJSON_IsTrue(force), "user") : -1;
cJSON_Delete(j); wui_release(w);
if (rc) return cst_http_send_err(c, 404, "active job not found");
return cst_http_send_str(c, 202, "application/json", "{\"ok\":true}");
}
/* POST /api/extensions/trust {"on": bool}: whether .agent/plugins and
* .agent/mcp.json of this workspace load, then reload. */
static int h_ext_trust_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cJSON *j = wui_body(c);
const cJSON *on = j ? cJSON_GetObjectItem(j, "on") : NULL;
if (!cJSON_IsBool(on)) {
cJSON_Delete(j);
return cst_http_send_err(c, 400, "需要 {\"on\": true 或 false}");
}
int want = cJSON_IsTrue(on);
cJSON_Delete(j);
if (cst_settings_trust(cst_agent_config(w->agent)->workspace_root, want) != 0)
return cst_http_send_err(c, 500, "cannot save the settings");
return wui_ext_reply(w, c);
}
WUI_EXCLUSIVE(h_session_new)
WUI_EXCLUSIVE(h_workspace)
WUI_EXCLUSIVE(h_session_switch)
WUI_EXCLUSIVE(h_session_delete)
WUI_EXCLUSIVE(h_settings)
WUI_EXCLUSIVE(h_perms_reset)
WUI_EXCLUSIVE(h_ext_reload)
WUI_EXCLUSIVE(h_workers)
WUI_EXCLUSIVE(h_ext_trust)
WUI_EXCLUSIVE(h_undo)
WUI_EXCLUSIVE(h_compact)
WUI_EXCLUSIVE(h_attach_upload)
WUI_EXCLUSIVE(h_attach_clear)
WUI_EXCLUSIVE(h_provider)
WUI_EXCLUSIVE(h_key)
static int h_quit(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
w->stopping = 1;
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
"{\"ok\":true}");
}
/* --- auth ---------------------------------------------------------------- */
static int token_matches(const wui_t *w, const char *given) {
if (!given || !w->token[0])
return 0;
size_t a = strlen(given), b = strlen(w->token);
if (a != b)
return 0;
unsigned diff = 0;
for (size_t i = 0; i < a; i++)
diff |= (unsigned char)given[i] ^ (unsigned char)w->token[i];
return diff == 0;
}
static int cookie_token(const char *cookie, char *out, size_t out_sz) {
const char *p = cookie;
while (p && *p) {
while (*p == ' ' || *p == ';')
p++;
if (strncmp(p, "cst_token=", 10) == 0) {
const char *v = p + 10;
const char *end = strchr(v, ';');
size_t n = end ? (size_t)(end - v) : strlen(v);
if (n >= out_sz)
n = out_sz - 1;
memcpy(out, v, n);
out[n] = 0;
return 0;
}
p = strchr(p, ';');
}
return -1;
}
/* Host header must name this machine's loopback when no token is required:
* a DNS-rebinding page would send its own name here. */
static int host_is_loopback(const char *host) {
if (!host || !host[0])
return 0;
while (*host == ' ')
host++;
if (strncmp(host, "127.0.0.1", 9) == 0) {
char next = host[9];
return next == 0 || next == ':';
}
if (strncmp(host, "localhost", 9) == 0) {
char next = host[9];
return next == 0 || next == ':';
}
if (strncmp(host, "[::1]", 5) == 0) {
char next = host[5];
return next == 0 || next == ':';
}
return 0;
}
static int strncasecmp_ascii(const char *a, const char *b, size_t n) {
for (size_t i = 0; i < n; i++) {
char x = a[i], y = b[i];
if (x >= 'A' && x <= 'Z')
x = (char)(x - 'A' + 'a');
if (y >= 'A' && y <= 'Z')
y = (char)(y - 'A' + 'a');
if (x != y)
return x < y ? -1 : 1;
if (!x)
return 0;
}
return 0;
}
/* A browser names the page that sent a request in Origin. Without a token
* that is the only thing that tells our own page from any site the user
* visits: a no-cors POST with a text/plain body needs no preflight and
* carries Host: 127.0.0.1. So it must be this very origin. */
static int origin_is_self(const char *origin, const char *host) {
if (!origin)
return 1; /* curl and friends; browsers always send it on POST */
while (*origin == ' ')
origin++;
const char *rest = NULL;
if (strncmp(origin, "http://", 7) == 0)
rest = origin + 7;
else if (strncmp(origin, "https://", 8) == 0)
rest = origin + 8;
if (!rest || !host)
return 0;
while (*host == ' ')
host++;
size_t n = strlen(host);
while (n && (host[n - 1] == ' ' || host[n - 1] == '\t'))
n--;
return strlen(rest) == n && strncasecmp_ascii(rest, host, n) == 0;
}
static int wui_auth(const cst_http_req_t *req, void *ud) {
wui_t *w = ud;
if (!w->need_auth)
return host_is_loopback(req->host) && origin_is_self(req->origin, req->host)
? 0
: -1;
char buf[128];
if (req->authorization && strncmp(req->authorization, "Bearer ", 7) == 0 &&
token_matches(w, req->authorization + 7))
return 0;
if (req->cookie && cookie_token(req->cookie, buf, sizeof(buf)) == 0 &&
token_matches(w, buf))
return 0;
if (cst_http_query(req, "token", buf, sizeof(buf)) == 0 &&
token_matches(w, buf))
return 0;
return -1;
}
/* --- startup ------------------------------------------------------------- */
static volatile sig_atomic_t g_webui_stop;
static void webui_on_signal(int sig) {
(void)sig;
g_webui_stop = 1;
}
static void wui_make_token(char *out, size_t out_sz) {
unsigned char raw[16];
size_t got = 0;
FILE *fp = fopen("/dev/urandom", "rb");
if (fp) {
got = fread(raw, 1, sizeof(raw), fp);
fclose(fp);
}
if (got != sizeof(raw)) {
unsigned seed = (unsigned)(os_now_ms() * 1000.0) ^ (unsigned)os_getpid();
for (size_t i = 0; i < sizeof(raw); i++) {
seed = seed * 1103515245u + 12345u;
raw[i] = (unsigned char)(seed >> 16);
}
}
static const char hex[] = "0123456789abcdef";
size_t o = 0;
for (size_t i = 0; i < sizeof(raw) && o + 2 < out_sz; i++) {
out[o++] = hex[raw[i] >> 4];
out[o++] = hex[raw[i] & 15];
}
out[o] = 0;
}
static void open_browser(const char *url) {
char cmd[600];
#if defined(_WIN32)
snprintf(cmd, sizeof(cmd), "start \"\" \"%s\"", url);
#elif defined(__APPLE__)
snprintf(cmd, sizeof(cmd), "open '%s'", url);
#else
/* Termux has no xdg-open; termux-open-url hands the URL to Android. */
const char *opener =
getenv("TERMUX_VERSION") ? "termux-open-url" : "xdg-open";
snprintf(cmd, sizeof(cmd), "%s '%s' >/dev/null 2>&1 &", opener, url);
#endif
os_run_result_t r;
memset(&r, 0, sizeof(r)); /* a NULL result makes the runner refuse */
os_run_shell_c(".", cmd, 0, &r);
os_run_result_free(&r);
}
int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) {
cst_webui_opts_t def;
if (!opts) {
memset(&def, 0, sizeof(def));
opts = &def;
}
wui_t *w = calloc(1, sizeof(*w));
if (!w)
return 1;
pthread_mutex_init(&w->mu, NULL);
w->agent = agent;
w->perm_timeout_s = opts->perm_timeout_s;
w->root = opts->root;
{
cst_settings_t st;
cst_settings_load(&st);
w->show_thinking = st.show_thinking;
}
pthread_cond_init(&w->cv, NULL);
snprintf(w->host, sizeof(w->host), "%s",
opts->host && opts->host[0] ? opts->host : "127.0.0.1");
if (opts->token && opts->token[0]) {
if (strlen(opts->token) >= sizeof(w->token)) {
fprintf(stderr, "webui: the token is longer than %d characters\n",
(int)sizeof(w->token) - 1);
pthread_cond_destroy(&w->cv);
pthread_mutex_destroy(&w->mu);
free(w);
return 1;
}
snprintf(w->token, sizeof(w->token), "%s", opts->token);
w->need_auth = 1;
} else if (strcmp(w->host, "127.0.0.1") != 0 &&
strcmp(w->host, "localhost") != 0 && strcmp(w->host, "::1") != 0) {
wui_make_token(w->token, sizeof(w->token));
w->need_auth = 1;
}
static cst_http_route_t routes[] = {
{"GET", "/", h_asset, NULL, 0, 0},
{"GET", "/api/state", h_state, NULL, 0, 0},
{"GET", "/api/task", h_task_get, NULL, 0, 0},
{"POST", "/api/task", h_task_control, NULL, 0, 0},
{"POST", "/api/jobs/cancel", h_job_cancel, NULL, 0, 0},
{"POST", "/api/worktree", h_worktree, NULL, 0, 0},
{"GET", "/api/session", h_session, NULL, 0, 0},
{"GET", "/api/events", h_events, NULL, 0, 0},
{"GET", "/api/sessions", h_sessions, NULL, 0, 0},
{"POST", "/api/sessions/new", h_session_new, NULL, 0, 0},
{"POST", "/api/sessions/switch", h_session_switch, NULL, 0, 0},
{"POST", "/api/sessions/rename", h_session_rename, NULL, 0, 0},
{"POST", "/api/sessions/delete", h_session_delete, NULL, 0, 0},
{"POST", "/api/retry", h_retry, NULL, 0, 0},
{"GET", "/api/export", h_export, NULL, 0, 0},
{"GET", "/api/diff", h_diff, NULL, 0, 0},
{"GET", "/api/settings", h_settings_get, NULL, 0, 0},
{"POST", "/api/settings", h_settings, NULL, 0, 0},
{"GET", "/api/permissions", h_perms_get, NULL, 0, 0},
{"DELETE", "/api/permissions", h_perms_reset, NULL, 0, 0},
{"POST", "/api/init", h_init, NULL, 0, 0},
{"POST", "/api/skill", h_skill, NULL, 0, 0},
{"GET", "/api/extensions", h_extensions, NULL, 0, 0},
{"POST", "/api/extensions/reload", h_ext_reload, NULL, 0, 0},
{"POST", "/api/extensions/trust", h_ext_trust, NULL, 0, 0},
{"POST", "/api/workspace", h_workspace, NULL, 0, 0},
{"POST", "/api/turn", h_turn, NULL, 0, 0},
{"POST", "/api/cancel", h_cancel, NULL, 0, 0},
{"POST", "/api/permission", h_permission, NULL, 0, 0},
{"POST", "/api/ask_user", h_ask_user, NULL, 0, 0},
{"POST", "/api/config", h_config, NULL, 0, 0},
{"GET", "/api/providers", h_providers, NULL, 0, 0},
{"GET", "/api/workers/config", h_workers_get, NULL, 0, 0},
{"POST", "/api/workers/config", h_workers, NULL, 0, 0},
{"POST", "/api/workers/cancel", h_worker_cancel, NULL, 0, 0},
{"GET", "/api/models", h_models, NULL, 0, 0},
{"POST", "/api/provider", h_provider, NULL, 0, 0},
{"POST", "/api/key", h_key, NULL, 0, 0},
{"POST", "/api/undo", h_undo, NULL, 0, 0},
{"POST", "/api/compact", h_compact, NULL, 0, 0},
{"POST", "/api/attach", h_attach_upload, NULL, 0, 32u * 1024u * 1024u},
{"DELETE", "/api/attach", h_attach_clear, NULL, 0, 0},
{"POST", "/api/quit", h_quit, NULL, 0, 0},
{"GET", "*", h_asset, NULL, 0, 0},
};
for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++) {
routes[i].ud = w;
routes[i].auth = w->need_auth || strcmp(routes[i].path, "/") != 0;
}
/* the shell page and its assets carry no token (app.js reads it from the
* URL and sets the cookie); h_asset checks /api/attach/ itself */
routes[0].auth = 0;
routes[sizeof(routes) / sizeof(routes[0]) - 1].auth = 0;
cst_http_opts_t ho;
memset(&ho, 0, sizeof(ho));
ho.host = w->host;
ho.port = opts->port;
ho.max_body = 1024 * 1024;
ho.max_conn = 32;
ho.routes = routes;
ho.nroutes = (int)(sizeof(routes) / sizeof(routes[0]));
ho.auth = wui_auth;
ho.auth_ud = w;
char err[256] = "";
w->srv = cst_http_start(&ho, err, sizeof(err));
if (!w->srv) {
fprintf(stderr, "webui: cannot listen on %s:%d: %s\n", w->host,
opts->port, err);
pthread_cond_destroy(&w->cv);
pthread_mutex_destroy(&w->mu);
free(w);
return 1;
}
char url[256];
snprintf(url, sizeof(url), "http://%s:%d/%s", strcmp(w->host, "0.0.0.0") == 0
? "127.0.0.1"
: w->host,
cst_http_port(w->srv),
w->need_auth ? "" : "");
fprintf(stderr, "webui: %s\n", url);
if (w->need_auth)
fprintf(stderr, "webui: token %s (append #token=%s to the URL)\n", w->token,
w->token);
fflush(stderr);
wui_emit_state(w);
if (opts->open_browser) {
char full[sizeof(url) + 80];
snprintf(full, sizeof(full), "%s%s%s", url, w->need_auth ? "#token=" : "",
w->need_auth ? w->token : "");
open_browser(full);
}
if (opts->prompt && opts->prompt[0]) {
char err2[256] = "";
if (wui_start_turn(w, opts->prompt, 0, err2, sizeof(err2)) != 0)
fprintf(stderr, "webui: %s\n", err2);
}
/* Serve until Ctrl+C, /api/quit or a signal. */
signal(SIGINT, webui_on_signal);
signal(SIGTERM, webui_on_signal);
while (!w->stopping && !g_webui_stop) {
struct timespec ts;
ts.tv_sec = 0;
ts.tv_nsec = 200 * 1000 * 1000;
nanosleep(&ts, NULL);
}
fprintf(stderr, "webui: shutting down\n");
/* Wake a turn parked on a permission/ask_user answer, cancel it and wait
* for its thread: it emits through w, and the agent is destroyed next. */
pthread_mutex_lock(&w->mu);
w->stopping = 1;
pthread_cond_broadcast(&w->cv);
int live = w->turn_live;
pthread_mutex_unlock(&w->mu);
cst_agent_request_cancel(w->agent); /* also a /api/compact in flight */
if (live) {
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += 30;
pthread_mutex_lock(&w->mu);
while (w->turn_live &&
pthread_cond_timedwait(&w->cv, &w->mu, &deadline) == 0)
;
live = w->turn_live;
pthread_mutex_unlock(&w->mu);
}
if (live) {
/* still running (a command ignoring cancel): the caller would destroy
* the agent under it, so leave without tearing anything down */
fprintf(stderr, "webui: the running turn did not stop in 30 s\n");
fflush(NULL);
_Exit(1);
}
if (cst_http_stop(w->srv) > 0) {
/* a handler still runs and uses w and the agent: same as above */
fprintf(stderr, "webui: a request did not finish in 30 s\n");
fflush(NULL);
_Exit(1);
}
cst_http_free(w->srv);
for (int i = 0; i < WUI_RING; i++)
free(w->ring[i].frame);
if (w->md)
cst_web_md_free(w->md);
pthread_cond_destroy(&w->cv);
pthread_mutex_destroy(&w->mu);
free(w);
return 0;
}