Usability pass: tool registry, TUI polish, saved keys and settings
Tools - Table-driven tool registry with permission classes and default/minimal profiles; new tools: edit_file, list_dir, glob, grep, delete_file, move_file, todo_write, web_fetch, ask_user (plus read_file paging). - Built-in Myers diff; write tools return unified diffs. - Persistent permission rules (.agent/permissions.json), per-turn undo snapshots (.agent/undo), AGENTS.md project notes, auto-compact at 80% of the model context, cancellable run_shell with process-group kill. TUI - Table-driven slash commands: /provider /key /effort /status /tools /permissions /yolo /undo /retry /diff /export /copy /rename /delete /init /max-steps /todo, with / and @ completion. - Permission modal with diff and prefix rules, ask_user modal, todo panel, header with model/effort/context, bracketed paste, line editing, wide-char cursor, NO_COLOR and ASCII fallback; Esc never quits. Startup and configuration - API keys are entered in the TUI (hidden) and saved per provider in <config>/credentials (0600); env variables are only a fallback. - Last provider, per-provider model and reasoning effort are remembered in <config>/settings.json; --effort/-e and /effort send reasoning_effort (low|medium|high|max) to models that accept it. - DeepSeek preset uses deepseek-flash / deepseek-v4-pro. - Session flags --new/--continue/--resume/--list-sessions, --yolo, --confirm. Compatibility and tests - ncurses detection, macOS/Homebrew support, make install, XDG config dir, POSIX cleanups; Gitea CI (gcc/clang/asan/musl/macOS). - Unit tests for diff, fs tools, registry, undo/perms and config; e2e fixtures multi_file_refactor and ask_or_todo; vendored cJSON.
This commit is contained in:
+219
-35
@@ -1,8 +1,14 @@
|
||||
#ifndef _DEFAULT_SOURCE
|
||||
#define _DEFAULT_SOURCE
|
||||
#endif
|
||||
#ifndef _GNU_SOURCE
|
||||
#define _GNU_SOURCE
|
||||
#endif
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <mach-o/dyld.h>
|
||||
#include <stdint.h>
|
||||
#endif
|
||||
#include "os_compat.h"
|
||||
|
||||
#include <errno.h>
|
||||
@@ -21,9 +27,12 @@
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <signal.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
@@ -146,6 +155,22 @@ int os_mkdir(const char *path, int mode) {
|
||||
#endif
|
||||
}
|
||||
|
||||
int os_mkdir_p(const char *path, int mode) {
|
||||
if (!path || !path[0])
|
||||
return -1;
|
||||
char tmp[OS_PATH_MAX];
|
||||
snprintf(tmp, sizeof(tmp), "%s", path);
|
||||
for (char *p = tmp + 1; *p; p++) {
|
||||
if (*p == '/' || *p == '\\') {
|
||||
char c = *p;
|
||||
*p = 0;
|
||||
os_mkdir(tmp, mode);
|
||||
*p = c;
|
||||
}
|
||||
}
|
||||
return os_mkdir(tmp, mode);
|
||||
}
|
||||
|
||||
int os_config_dir(char *out, size_t out_sz) {
|
||||
if (!out || out_sz < 8)
|
||||
return -1;
|
||||
@@ -157,10 +182,15 @@ int os_config_dir(char *out, size_t out_sz) {
|
||||
return -1;
|
||||
int n = snprintf(out, out_sz, "%s\\cst-agent", base);
|
||||
#else
|
||||
const char *xdg = getenv("XDG_CONFIG_HOME");
|
||||
const char *home = getenv("HOME");
|
||||
if (!home || !home[0])
|
||||
int n;
|
||||
if (xdg && xdg[0] == '/')
|
||||
n = snprintf(out, out_sz, "%s/cst-agent", xdg);
|
||||
else if (home && home[0])
|
||||
n = snprintf(out, out_sz, "%s/.config/cst-agent", home);
|
||||
else
|
||||
return -1;
|
||||
int n = snprintf(out, out_sz, "%s/.config/cst-agent", home);
|
||||
#endif
|
||||
if (n < 0 || (size_t)n >= out_sz)
|
||||
return -1;
|
||||
@@ -258,7 +288,12 @@ int os_exe_dir(char *out, size_t out_sz) {
|
||||
return 0;
|
||||
#else
|
||||
char buf[OS_PATH_MAX];
|
||||
#ifdef __APPLE__
|
||||
uint32_t asz = (uint32_t)sizeof(buf);
|
||||
ssize_t n = _NSGetExecutablePath(buf, &asz) == 0 ? (ssize_t)strlen(buf) : -1;
|
||||
#else
|
||||
ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1);
|
||||
#endif
|
||||
if (n >= 0) {
|
||||
buf[n] = 0;
|
||||
char *slash = strrchr(buf, '/');
|
||||
@@ -343,6 +378,77 @@ int os_resolve_shell(char *out, size_t out_sz) {
|
||||
#endif
|
||||
}
|
||||
|
||||
static volatile int *g_run_cancel;
|
||||
static int g_run_timeout_s;
|
||||
|
||||
void os_set_cancel_flag(volatile int *flag) { g_run_cancel = flag; }
|
||||
|
||||
void os_set_run_timeout(int seconds) { g_run_timeout_s = seconds; }
|
||||
|
||||
static int run_timeout_s(void) {
|
||||
if (g_run_timeout_s > 0)
|
||||
return g_run_timeout_s;
|
||||
const char *e = getenv("CST_SHELL_TIMEOUT");
|
||||
if (e && atoi(e) > 0)
|
||||
return atoi(e);
|
||||
return 120;
|
||||
}
|
||||
|
||||
double os_now_ms(void) {
|
||||
#ifdef _WIN32
|
||||
static LARGE_INTEGER freq;
|
||||
LARGE_INTEGER now;
|
||||
if (!freq.QuadPart)
|
||||
QueryPerformanceFrequency(&freq);
|
||||
QueryPerformanceCounter(&now);
|
||||
return (double)now.QuadPart * 1000.0 / (double)freq.QuadPart;
|
||||
#else
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1e6;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Append to capture buffer, keeping at most max_out bytes. */
|
||||
static int cap_append(char **buf, size_t *len, size_t *cap, size_t max_out,
|
||||
const char *data, size_t n, int *truncated) {
|
||||
if (*len >= max_out) {
|
||||
*truncated = 1;
|
||||
return 0;
|
||||
}
|
||||
if (*len + n > max_out) {
|
||||
n = max_out - *len;
|
||||
*truncated = 1;
|
||||
}
|
||||
if (*len + n + 1 > *cap) {
|
||||
size_t nc = *cap ? *cap * 2 : 4096;
|
||||
while (nc < *len + n + 1)
|
||||
nc *= 2;
|
||||
char *nb = realloc(*buf, nc);
|
||||
if (!nb)
|
||||
return -1;
|
||||
*buf = nb;
|
||||
*cap = nc;
|
||||
}
|
||||
memcpy(*buf + *len, data, n);
|
||||
*len += n;
|
||||
(*buf)[*len] = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void cap_note(char **buf, size_t *len, size_t *cap, const char *note) {
|
||||
size_t n = strlen(note);
|
||||
if (*len + n + 1 > *cap) {
|
||||
char *nb = realloc(*buf, *len + n + 1);
|
||||
if (!nb)
|
||||
return;
|
||||
*buf = nb;
|
||||
*cap = *len + n + 1;
|
||||
}
|
||||
memcpy(*buf + *len, note, n + 1);
|
||||
*len += n;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
|
||||
os_run_result_t *r) {
|
||||
@@ -396,7 +502,9 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
|
||||
si.dwFlags = STARTF_USESTDHANDLES;
|
||||
si.hStdOutput = wr;
|
||||
si.hStdError = wr;
|
||||
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
|
||||
HANDLE nul = CreateFileA("NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
&sa, OPEN_EXISTING, 0, NULL);
|
||||
si.hStdInput = nul != INVALID_HANDLE_VALUE ? nul : GetStdHandle(STD_INPUT_HANDLE);
|
||||
PROCESS_INFORMATION pi = {0};
|
||||
|
||||
char *cmd_mut = _strdup(cmdline);
|
||||
@@ -409,6 +517,8 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
|
||||
cwd && cwd[0] ? cwd : NULL, &si, &pi);
|
||||
free(cmd_mut);
|
||||
CloseHandle(wr);
|
||||
if (nul != INVALID_HANDLE_VALUE)
|
||||
CloseHandle(nul);
|
||||
if (!ok) {
|
||||
CloseHandle(rd);
|
||||
return -1;
|
||||
@@ -422,30 +532,54 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
|
||||
CloseHandle(pi.hThread);
|
||||
return -1;
|
||||
}
|
||||
buf[0] = 0;
|
||||
int truncated = 0, killed = 0;
|
||||
double deadline = os_now_ms() + run_timeout_s() * 1000.0;
|
||||
for (;;) {
|
||||
char tmp[1024];
|
||||
DWORD n = 0;
|
||||
if (!ReadFile(rd, tmp, sizeof(tmp), &n, NULL) || n == 0)
|
||||
break;
|
||||
if (len + n + 1 > cap) {
|
||||
size_t nc = cap * 2;
|
||||
if (nc < len + n + 1)
|
||||
nc = len + n + 1;
|
||||
char *nb = realloc(buf, nc);
|
||||
if (!nb)
|
||||
DWORD avail = 0;
|
||||
if (!PeekNamedPipe(rd, NULL, 0, NULL, &avail, NULL))
|
||||
break; /* writer closed */
|
||||
if (avail > 0) {
|
||||
char tmp[4096];
|
||||
DWORD n = 0;
|
||||
if (!ReadFile(rd, tmp, sizeof(tmp), &n, NULL) || n == 0)
|
||||
break;
|
||||
buf = nb;
|
||||
cap = nc;
|
||||
cap_append(&buf, &len, &cap, max_out, tmp, n, &truncated);
|
||||
continue;
|
||||
}
|
||||
memcpy(buf + len, tmp, n);
|
||||
len += n;
|
||||
if (len >= max_out)
|
||||
if (WaitForSingleObject(pi.hProcess, 50) == WAIT_OBJECT_0) {
|
||||
/* drain what is left */
|
||||
while (PeekNamedPipe(rd, NULL, 0, NULL, &avail, NULL) && avail > 0) {
|
||||
char tmp[4096];
|
||||
DWORD n = 0;
|
||||
if (!ReadFile(rd, tmp, sizeof(tmp), &n, NULL) || n == 0)
|
||||
break;
|
||||
cap_append(&buf, &len, &cap, max_out, tmp, n, &truncated);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if ((g_run_cancel && *g_run_cancel) || os_now_ms() > deadline) {
|
||||
killed = (g_run_cancel && *g_run_cancel) ? 2 : 1;
|
||||
TerminateProcess(pi.hProcess, killed == 1 ? 124 : 130);
|
||||
break;
|
||||
}
|
||||
}
|
||||
CloseHandle(rd);
|
||||
WaitForSingleObject(pi.hProcess, INFINITE);
|
||||
DWORD code = 1;
|
||||
GetExitCodeProcess(pi.hProcess, &code);
|
||||
if (truncated)
|
||||
cap_note(&buf, &len, &cap, "\n...(output truncated)\n");
|
||||
if (killed == 1) {
|
||||
char note[96];
|
||||
snprintf(note, sizeof(note), "\n[cst: killed after %ds timeout]\n",
|
||||
run_timeout_s());
|
||||
cap_note(&buf, &len, &cap, note);
|
||||
code = 124;
|
||||
} else if (killed == 2) {
|
||||
cap_note(&buf, &len, &cap, "\n[cst: cancelled]\n");
|
||||
code = 130;
|
||||
}
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(pi.hThread);
|
||||
buf[len] = 0;
|
||||
@@ -473,7 +607,13 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
|
||||
return -1;
|
||||
}
|
||||
if (pid == 0) {
|
||||
setpgid(0, 0);
|
||||
close(pipefd[0]);
|
||||
int devnull = open("/dev/null", O_RDONLY);
|
||||
if (devnull >= 0) {
|
||||
dup2(devnull, STDIN_FILENO);
|
||||
close(devnull);
|
||||
}
|
||||
dup2(pipefd[1], STDOUT_FILENO);
|
||||
dup2(pipefd[1], STDERR_FILENO);
|
||||
close(pipefd[1]);
|
||||
@@ -482,41 +622,85 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
|
||||
execvp(argv[0], (char *const *)argv);
|
||||
_exit(127);
|
||||
}
|
||||
setpgid(pid, pid); /* also in parent: avoid racing the child */
|
||||
close(pipefd[1]);
|
||||
size_t cap = 4096, len = 0;
|
||||
char *buf = malloc(cap);
|
||||
if (!buf) {
|
||||
close(pipefd[0]);
|
||||
kill(-pid, SIGKILL);
|
||||
waitpid(pid, NULL, 0);
|
||||
return -1;
|
||||
}
|
||||
buf[0] = 0;
|
||||
int truncated = 0, killed = 0;
|
||||
int status = 0, reaped = 0, bg_killed = 0;
|
||||
double reaped_at = 0, killed_at = 0;
|
||||
double deadline = os_now_ms() + run_timeout_s() * 1000.0;
|
||||
for (;;) {
|
||||
if (len + 1024 > cap) {
|
||||
cap *= 2;
|
||||
char *nb = realloc(buf, cap);
|
||||
if (!nb)
|
||||
struct pollfd pfd = {pipefd[0], POLLIN, 0};
|
||||
int pr = poll(&pfd, 1, 100);
|
||||
if (pr < 0 && errno != EINTR)
|
||||
break;
|
||||
if (pr > 0) {
|
||||
char tmp[4096];
|
||||
ssize_t n = read(pipefd[0], tmp, sizeof(tmp));
|
||||
if (n == 0)
|
||||
break; /* every writer closed */
|
||||
if (n > 0)
|
||||
cap_append(&buf, &len, &cap, max_out, tmp, (size_t)n, &truncated);
|
||||
else if (errno != EINTR && errno != EAGAIN)
|
||||
break;
|
||||
buf = nb;
|
||||
}
|
||||
ssize_t n = read(pipefd[0], buf + len, 1024);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR)
|
||||
continue;
|
||||
break;
|
||||
double now = os_now_ms();
|
||||
if (!reaped && waitpid(pid, &status, WNOHANG) == pid) {
|
||||
reaped = 1;
|
||||
reaped_at = now;
|
||||
}
|
||||
if (n == 0)
|
||||
break;
|
||||
len += (size_t)n;
|
||||
if (len >= max_out)
|
||||
/* The shell exited but a background child still holds the pipe
|
||||
* (`server &`): give it a moment, then end the group. */
|
||||
if (reaped && !killed && !bg_killed && now - reaped_at > 1000) {
|
||||
kill(-pid, SIGKILL);
|
||||
bg_killed = 1;
|
||||
killed_at = now;
|
||||
}
|
||||
/* Checked on every pass, so a chatty command still stops promptly. */
|
||||
if (!reaped && !killed &&
|
||||
((g_run_cancel && *g_run_cancel) || now > deadline)) {
|
||||
killed = (g_run_cancel && *g_run_cancel) ? 2 : 1;
|
||||
killed_at = now;
|
||||
kill(-pid, SIGTERM);
|
||||
}
|
||||
if (killed && !reaped && now - killed_at > 1000)
|
||||
kill(-pid, SIGKILL);
|
||||
/* a writer that left the process group can keep the pipe open */
|
||||
if ((killed || bg_killed) && now - killed_at > 3000)
|
||||
break;
|
||||
}
|
||||
close(pipefd[0]);
|
||||
int status = 0;
|
||||
waitpid(pid, &status, 0);
|
||||
buf[len] = 0;
|
||||
if (pid > 0 && !reaped) {
|
||||
if (killed)
|
||||
kill(-pid, SIGKILL);
|
||||
waitpid(pid, &status, 0);
|
||||
}
|
||||
if (truncated)
|
||||
cap_note(&buf, &len, &cap, "\n...(output truncated)\n");
|
||||
if (bg_killed)
|
||||
cap_note(&buf, &len, &cap,
|
||||
"\n[cst: background processes left running were stopped]\n");
|
||||
r->out = buf;
|
||||
r->out_len = len;
|
||||
r->exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 1;
|
||||
if (killed == 1) {
|
||||
char note[96];
|
||||
snprintf(note, sizeof(note), "\n[cst: killed after %ds timeout]\n",
|
||||
run_timeout_s());
|
||||
cap_note(&r->out, &r->out_len, &cap, note);
|
||||
r->exit_code = 124;
|
||||
} else if (killed == 2) {
|
||||
cap_note(&r->out, &r->out_len, &cap, "\n[cst: cancelled]\n");
|
||||
r->exit_code = 130;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user