esengine--deepseek-reasonix
1373 行
45 KiB
Go
1373 行
45 KiB
Go
// Package serve exposes a control.Controller over HTTP: the typed event stream
|
|
// as Server-Sent Events, and the commands as small JSON POST endpoints. It is a
|
|
// second frontend alongside the chat TUI — proof that the controller is
|
|
// transport-agnostic, and the basis for a browser/desktop client. One server
|
|
// drives one session; multiple browser tabs share it.
|
|
package serve
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/boot"
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/control"
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/jobs"
|
|
"reasonix/internal/nilutil"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/store"
|
|
)
|
|
|
|
//go:embed index.html
|
|
var indexHTML []byte
|
|
|
|
//go:embed logo-wordmark.svg
|
|
var logoWordmarkSVG []byte
|
|
|
|
// Server wires a controller to its HTTP surface. The Broadcaster must be the
|
|
// same sink the controller was constructed with, so events reach SSE clients.
|
|
type Server struct {
|
|
mu sync.RWMutex // guards ctrl, which switchModel swaps at runtime
|
|
// bindMu serializes every entry point that changes the active session
|
|
// path — /resume, /new, /fork, and switchModel. net/http runs handlers
|
|
// concurrently and serve serves multiple browser tabs, so without this
|
|
// two interleaved rebinds can leave the controller writing one session
|
|
// while the lease keeper guards another (the exact split this feature
|
|
// exists to prevent). It also keeps switchModel's Snapshot/Build/Close
|
|
// off s.mu, as the narrower switchMu did before it was widened.
|
|
bindMu sync.Mutex
|
|
ctrl control.SessionAPI
|
|
bc *Broadcaster
|
|
// buildController builds the replacement controller during a model switch.
|
|
// Nil in production (switchModel falls back to boot.Build); tests inject a
|
|
// fake so switchModel can be exercised without real provider IO.
|
|
buildController func(ctx context.Context, ref string) (*control.Controller, error)
|
|
titleProv provider.Provider // lightweight flash provider for session titles
|
|
titlePrice *provider.Pricing
|
|
titles *titleCache
|
|
auth *authGate // nil when auth is disabled
|
|
// leases guards the active session file against other runtimes (a desktop
|
|
// window, another CLI). Wired by the serve CLI command with the keeper that
|
|
// already holds the startup session's lease; nil (tests, embedded use)
|
|
// disables lease gating.
|
|
leases *control.SessionLeaseKeeper
|
|
}
|
|
|
|
// New builds a Server. bc must be the controller's event sink.
|
|
// serveCfg controls authentication (none, token, or password).
|
|
func New(ctrl control.SessionAPI, bc *Broadcaster, serveCfg config.ServeConfig) *Server {
|
|
s := &Server{
|
|
ctrl: ctrl,
|
|
bc: bc,
|
|
titles: newTitleCache(ctrl.SessionDir()),
|
|
auth: newAuthGate(serveCfg),
|
|
}
|
|
s.initTitleProvider()
|
|
return s
|
|
}
|
|
|
|
// ctl returns the current controller. Handlers must read it through here, never
|
|
// the field directly, because switchModel replaces it under the write lock.
|
|
func (s *Server) ctl() control.SessionAPI {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.ctrl
|
|
}
|
|
|
|
// SetSessionLeases hands the server the session-lease keeper that guards its
|
|
// active session file. The write-binding endpoints (/resume, /new, /fork and
|
|
// model switches that rotate the path) then move the lease along with the
|
|
// active session and refuse to bind a session held by another runtime.
|
|
// Call it before serving; a nil keeper leaves lease gating off.
|
|
func (s *Server) SetSessionLeases(k *control.SessionLeaseKeeper) {
|
|
s.leases = k
|
|
}
|
|
|
|
// rebindSessionLease moves the server's session lease to path. A nil keeper
|
|
// gates nothing (tests, embedded use).
|
|
func (s *Server) rebindSessionLease(path string) error {
|
|
if s.leases == nil {
|
|
return nil
|
|
}
|
|
return s.leases.Rebind(path)
|
|
}
|
|
|
|
// resumeBindHookForTest, when set, runs inside /resume's critical sequence
|
|
// between the lease rebind and the controller Resume. Tests use it to force
|
|
// the interleaving bindMu exists to prevent; production never sets it.
|
|
var resumeBindHookForTest func()
|
|
|
|
// sessionInUseError renders a lease refusal for HTTP clients using the shared
|
|
// CLI wording, without the session file path.
|
|
func sessionInUseError(err error) string {
|
|
return control.SessionInUseMessage(err) + "; " + control.SessionLeaseCloseHint
|
|
}
|
|
|
|
// AuthToken returns the pre-shared token when in token mode, or "" otherwise.
|
|
func (s *Server) AuthToken() string {
|
|
if s.auth == nil {
|
|
return ""
|
|
}
|
|
return s.auth.Token()
|
|
}
|
|
|
|
// AuthMode returns the authentication mode: "none", "token", or "password".
|
|
func (s *Server) AuthMode() string {
|
|
if s.auth == nil {
|
|
return "none"
|
|
}
|
|
return s.auth.Mode()
|
|
}
|
|
|
|
// initTitleProvider builds a lightweight flash-model provider used solely to
|
|
// generate short session titles. Errors are silently swallowed — title
|
|
// generation is best-effort, and the server works fine without it.
|
|
func (s *Server) initTitleProvider() {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return
|
|
}
|
|
entry, ok := cfg.ResolveModel("deepseek-flash")
|
|
if !ok {
|
|
return
|
|
}
|
|
prov, err := provider.New(entry.Kind, provider.Config{
|
|
Name: entry.Name,
|
|
BaseURL: entry.BaseURL,
|
|
Model: entry.Model,
|
|
APIKey: entry.APIKey(),
|
|
Extra: map[string]any{"effort": "off"},
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
s.titleProv = prov
|
|
s.titlePrice = entry.Price
|
|
}
|
|
|
|
// switchModel rebuilds the controller with a new model, carrying over the
|
|
// conversation history. This replicates the TUI/desktop model-switch path.
|
|
//
|
|
// The heavy steps — Snapshot (may touch disk), Build (provider init IO), and the
|
|
// old controller's Close (jobs.CloseWithGrace up to 15s + SessionEnd hook) — all
|
|
// run OFF s.mu. Holding the write lock across them would wedge every HTTP handler
|
|
// on s.ctl()'s RLock for the duration, stalling the whole serve frontend
|
|
// (mirrors the acp rebuildSession fix and PR #5920). bindMu serializes the
|
|
// switch against every other session-path-changing entry point (/resume,
|
|
// /new, /fork), preserving the old "second switch waits" semantics without
|
|
// pinning s.mu.
|
|
func (s *Server) switchModel(ctx context.Context, ref string) error {
|
|
s.bindMu.Lock()
|
|
defer s.bindMu.Unlock()
|
|
|
|
// Snapshot the current controller under a short read of s.mu only.
|
|
cur := s.ctl()
|
|
if cur.Running() {
|
|
return fmt.Errorf("cannot switch model while a turn is running")
|
|
}
|
|
|
|
// Off-lock: snapshot, carry history, and build the replacement. None of these
|
|
// touch s.mu, so concurrent handlers keep reading the live controller.
|
|
if err := cur.Snapshot(); err != nil {
|
|
slog.Warn("serve: snapshot before model switch", "err", err)
|
|
}
|
|
// Capture the continue path and history only after Snapshot: a snapshot
|
|
// conflict can retarget cur to a recovery branch (or adopt the newer disk
|
|
// transcript), and a pre-snapshot capture would bind the rebuilt controller
|
|
// back to the original file, re-conflicting on every later save.
|
|
prevPath := cur.SessionPath()
|
|
carried := cur.History()
|
|
|
|
newCtrl, err := s.build(ctx, ref)
|
|
if err != nil {
|
|
return fmt.Errorf("switch model: %w", err)
|
|
}
|
|
// Keep the carried conversation in its existing file so the switch doesn't
|
|
// orphan a duplicate (#2807).
|
|
newPath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
|
|
newCtrl.AdoptHistory(carried, newPath)
|
|
|
|
// Publish the swap under a short write lock. bindMu already serializes
|
|
// switches — today the only writer of s.ctrl — so the identity re-check is
|
|
// defensive: it keeps a future controller-swapping path (or a test doing so)
|
|
// from being silently clobbered after the off-lock build. On a mismatch,
|
|
// discard the fresh controller off-lock instead of leaking it.
|
|
s.mu.Lock()
|
|
if s.ctrl != cur {
|
|
s.mu.Unlock()
|
|
newCtrl.Close()
|
|
return fmt.Errorf("switch model: session changed during switch")
|
|
}
|
|
s.ctrl = newCtrl
|
|
s.mu.Unlock()
|
|
|
|
// The lease follows the active session file. Rebind is a no-op for the
|
|
// common carried case (newPath == held path); it moves when a previously
|
|
// file-less session got a fresh path here, or when the pre-switch snapshot
|
|
// recovered onto a recovery branch. Both targets are fresh files created
|
|
// by this process, so failure is theoretical.
|
|
if err := s.rebindSessionLease(newPath); err != nil {
|
|
slog.Warn("serve: session lease after model switch", "err", err)
|
|
}
|
|
|
|
// Off-lock: tear down the old controller. Close can block up to 15s.
|
|
cur.Close()
|
|
return nil
|
|
}
|
|
|
|
// build returns the replacement controller for a model switch, using the
|
|
// injected builder in tests and boot.Build in production.
|
|
func (s *Server) build(ctx context.Context, ref string) (*control.Controller, error) {
|
|
if s.buildController != nil {
|
|
return s.buildController(ctx, ref)
|
|
}
|
|
return boot.Build(ctx, boot.Options{
|
|
Model: ref,
|
|
Sink: s.bc,
|
|
Stderr: os.Stderr,
|
|
})
|
|
}
|
|
|
|
// switchEffort persists a new reasoning-effort level for the active provider and
|
|
// rebuilds via switchModel (which serializes on bindMu).
|
|
func (s *Server) switchEffort(ctx context.Context, level string) error {
|
|
cur := s.ctl()
|
|
if cur.Running() {
|
|
return fmt.Errorf("cannot change effort while a turn is running")
|
|
}
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return fmt.Errorf("load config: %w", err)
|
|
}
|
|
ref := currentModelRef(cur)
|
|
entry, ok := cfg.ResolveModel(ref)
|
|
if !ok {
|
|
return fmt.Errorf("cannot resolve current provider %q", ref)
|
|
}
|
|
if !config.EffortCapabilityForEntry(entry).Supported {
|
|
return fmt.Errorf("effort is not configurable for %s", entry.Name)
|
|
}
|
|
effort, err := config.NormalizeEffort(entry, level)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
editPath := config.UserConfigPath()
|
|
if editPath == "" {
|
|
return fmt.Errorf("no config file found")
|
|
}
|
|
// Lock only the load-modify-save cycle; switchModel below rebuilds the
|
|
// controller and must not hold the config edit lock.
|
|
if err := func() error {
|
|
unlock := config.LockUserConfigEdits()
|
|
defer unlock()
|
|
edit := config.LoadForEdit(editPath)
|
|
if err := applyEffortEdit(edit, entry, effort); err != nil {
|
|
return err
|
|
}
|
|
if err := edit.SaveTo(editPath); err != nil {
|
|
return fmt.Errorf("save config: %w", err)
|
|
}
|
|
return nil
|
|
}(); err != nil {
|
|
return err
|
|
}
|
|
return s.switchModel(ctx, entry.Name+"/"+entry.Model)
|
|
}
|
|
|
|
// applyEffortEdit writes effort onto entry within edit, mirroring CLI/desktop
|
|
// SetEffort: upsert the provider when the user config has no block for it yet, and
|
|
// enable adaptive thinking for Anthropic so the effort knob actually engages.
|
|
func applyEffortEdit(edit *config.Config, entry *config.ProviderEntry, effort string) error {
|
|
if _, ok := edit.Provider(entry.Name); !ok {
|
|
if err := edit.UpsertProvider(*entry); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if entry.Kind == "anthropic" && effort != "" && entry.Thinking == "" {
|
|
if err := edit.SetProviderThinking(entry.Name, "adaptive"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return edit.SetProviderEffort(entry.Name, effort)
|
|
}
|
|
|
|
// Handler returns the HTTP routes: GET / (a minimal browser client), GET /events
|
|
// (SSE), GET /history, GET /context, and POST command endpoints.
|
|
// CORS is NOT applied by default — same-origin policy protects the unauthenticated
|
|
// agent endpoints. Call HandlerWithCORS to opt in for local development.
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.handler()
|
|
}
|
|
|
|
// HandlerWithCORS returns the same routes as Handler but adds permissive CORS
|
|
// headers so a dev frontend on a different origin (e.g. Vite on :5173) can
|
|
// reach the server. Do NOT use in production — the server has no auth.
|
|
func (s *Server) HandlerWithCORS(origin string) http.Handler {
|
|
return corsMiddleware(s.handler(), origin)
|
|
}
|
|
|
|
func (s *Server) handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /", s.index)
|
|
mux.HandleFunc("GET /assets/logo-wordmark.svg", s.logoWordmark)
|
|
mux.HandleFunc("GET /events", s.events)
|
|
mux.HandleFunc("GET /history", s.history)
|
|
mux.HandleFunc("GET /context", s.context)
|
|
mux.HandleFunc("POST /submit", s.submit)
|
|
mux.HandleFunc("POST /cancel", s.cancel)
|
|
mux.HandleFunc("POST /approve", s.approve)
|
|
mux.HandleFunc("POST /plan", s.plan)
|
|
mux.HandleFunc("POST /compact", s.compact)
|
|
mux.HandleFunc("POST /new", s.newSession)
|
|
mux.HandleFunc("POST /rewind", s.rewind)
|
|
mux.HandleFunc("POST /fork", s.fork)
|
|
mux.HandleFunc("POST /summarize", s.summarize)
|
|
mux.HandleFunc("POST /tool-approval-mode", s.toolApprovalMode)
|
|
mux.HandleFunc("POST /auto-approve-tools", s.autoApproveTools)
|
|
mux.HandleFunc("POST /bypass", s.bypass)
|
|
mux.HandleFunc("POST /goal", s.goal)
|
|
mux.HandleFunc("POST /answer", s.answer)
|
|
mux.HandleFunc("POST /resume", s.resume)
|
|
mux.HandleFunc("POST /forget", s.forget)
|
|
mux.HandleFunc("GET /checkpoints", s.checkpoints)
|
|
mux.HandleFunc("GET /branches", s.branches)
|
|
mux.HandleFunc("GET /models", s.models)
|
|
mux.HandleFunc("GET /status", s.status)
|
|
mux.HandleFunc("GET /sessions", s.sessions)
|
|
mux.HandleFunc("GET /skills", s.skills)
|
|
mux.HandleFunc("GET /todos", s.todos)
|
|
mux.HandleFunc("POST /delete-session", s.deleteSession)
|
|
return logMiddleware(s.auth.middleware(csrfGuard(mux)))
|
|
}
|
|
|
|
// csrfGuard rejects state-changing requests that don't carry a JSON content type.
|
|
// The command endpoints have no auth and bind to localhost, so a page the user
|
|
// visits could otherwise drive them with a simple cross-origin POST (text/plain,
|
|
// no preflight) — submitting prompts or auto-approving tool calls. Requiring
|
|
// application/json forces a CORS preflight the unauthenticated server never
|
|
// answers, blocking cross-site requests; the same-origin frontend (which always
|
|
// sends JSON) is unaffected.
|
|
func csrfGuard(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost {
|
|
ct := r.Header.Get("Content-Type")
|
|
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
|
ct = ct[:i]
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(ct), "application/json") {
|
|
http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
|
|
return
|
|
}
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// Run serves until the process is killed. Interactive approval is enabled so
|
|
// "ask" decisions surface as approval_request events answered via POST /approve.
|
|
func (s *Server) Run(addr string) error {
|
|
s.ctl().EnableInteractiveApproval()
|
|
return http.ListenAndServe(addr, s.Handler())
|
|
}
|
|
|
|
// RunGraceful serves with graceful shutdown. It listens for SIGINT/SIGTERM on
|
|
// the provided context and drains active connections for up to 10 seconds
|
|
// before returning.
|
|
func (s *Server) RunGraceful(ctx context.Context, addr string) error {
|
|
s.ctl().EnableInteractiveApproval()
|
|
srv := &http.Server{
|
|
Addr: addr,
|
|
Handler: s.Handler(),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- srv.ListenAndServe()
|
|
}()
|
|
select {
|
|
case err := <-errCh:
|
|
return err
|
|
case <-ctx.Done():
|
|
slog.Info("serve: shutting down gracefully")
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
slog.Warn("serve: graceful shutdown failed", "err", err)
|
|
}
|
|
return <-errCh
|
|
}
|
|
}
|
|
|
|
func (s *Server) index(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = config.MigrateLegacyIfNeeded()
|
|
lang := "auto"
|
|
if cfg, err := config.Load(); err == nil {
|
|
if dl := cfg.DesktopLanguage(); dl != "" {
|
|
lang = dl
|
|
}
|
|
}
|
|
html := string(indexHTML)
|
|
html = strings.ReplaceAll(html, "__LANG__", lang)
|
|
_, _ = w.Write([]byte(html))
|
|
}
|
|
|
|
func (s *Server) logoWordmark(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
_, _ = w.Write(logoWordmarkSVG)
|
|
}
|
|
|
|
// sseKeepaliveInterval is how often the /events handler emits a `: ping`
|
|
// SSE comment. Most reverse proxies (nginx, ALB, Cloudflare) close idle
|
|
// upstream connections after 30–60 s; a long quiet turn (the agent
|
|
// thinking, the model generating a single long response) easily hits
|
|
// that window. The comment is one byte on the wire and is dropped by
|
|
// the EventSource client, so it's a no-op for the consumer while it
|
|
// keeps the TCP socket warm for the proxy.
|
|
const sseKeepaliveInterval = 15 * time.Second
|
|
|
|
// events streams the controller's event flow as SSE until the client
|
|
// disconnects. Each event is one `data:` frame of the JSON wire form.
|
|
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
|
|
ch, unsubscribe := s.bc.Subscribe()
|
|
defer unsubscribe()
|
|
|
|
fmt.Fprint(w, ": connected\n\n") // open the stream immediately
|
|
flusher.Flush()
|
|
|
|
keepalive := time.NewTicker(sseKeepaliveInterval)
|
|
defer keepalive.Stop()
|
|
|
|
for {
|
|
select {
|
|
case data, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
|
flusher.Flush()
|
|
case <-keepalive.C:
|
|
// SSE comment lines start with `:` and are ignored by the
|
|
// client. Emit one every sseKeepaliveInterval so the
|
|
// upstream socket stays warm; without this, a long quiet
|
|
// turn (e.g. a model thinking) lets a proxy like nginx
|
|
// or an ALB close the idle connection and the next
|
|
// event arrives on a half-closed stream.
|
|
fmt.Fprint(w, ": ping\n\n")
|
|
flusher.Flush()
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// submit runs raw user input as a turn (slash commands and @-references
|
|
// resolved by the controller). Returns 202 — output arrives on the event stream.
|
|
func (s *Server) submit(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Input string `json:"input"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Input == "" {
|
|
http.Error(w, "missing input", http.StatusBadRequest)
|
|
return
|
|
}
|
|
trimmed := strings.TrimSpace(body.Input)
|
|
if strings.HasPrefix(trimmed, "!") {
|
|
http.Error(w, "shell commands are unavailable over HTTP", http.StatusForbidden)
|
|
return
|
|
}
|
|
// Intercept /model <ref> for runtime model switching (the controller's
|
|
// Submit path only lists models — switching is frontend-specific).
|
|
if strings.HasPrefix(trimmed, "/model ") {
|
|
ref := strings.TrimSpace(strings.TrimPrefix(trimmed, "/model"))
|
|
if ref != "" {
|
|
if err := s.switchModel(r.Context(), ref); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
}
|
|
// Intercept /effort <level> for reasoning effort switching.
|
|
if strings.HasPrefix(trimmed, "/effort ") {
|
|
level := strings.TrimSpace(strings.TrimPrefix(trimmed, "/effort"))
|
|
if level != "" {
|
|
if err := s.switchEffort(r.Context(), level); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
}
|
|
s.ctl().SubmitHTTP(body.Input)
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}
|
|
|
|
func (s *Server) cancel(w http.ResponseWriter, _ *http.Request) {
|
|
s.ctl().Cancel()
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) approve(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
ID string `json:"id"`
|
|
Allow bool `json:"allow"`
|
|
Session bool `json:"session"`
|
|
Persist bool `json:"persist"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID == "" {
|
|
http.Error(w, "missing id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.ctl().Approve(body.ID, body.Allow, body.Session, body.Persist)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) plan(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
On bool `json:"on"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, "bad body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.ctl().SetPlanMode(body.On)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) compact(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.ctl().Compact(r.Context(), ""); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Persist the compacted session to disk — ctrl.Compact() only mutates in-memory.
|
|
if err := s.ctl().Snapshot(); err != nil {
|
|
slog.Warn("serve: snapshot after compact", "err", err)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) newSession(w http.ResponseWriter, _ *http.Request) {
|
|
// Session-path-changing entry point: serialize with /resume, /fork, and
|
|
// switchModel so the controller and the lease keeper move together.
|
|
s.bindMu.Lock()
|
|
defer s.bindMu.Unlock()
|
|
if err := s.ctl().NewSession(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Fresh path — the lease follows it; failure is theoretical but not silent.
|
|
if err := s.rebindSessionLease(s.ctl().SessionPath()); err != nil {
|
|
http.Error(w, sessionInUseError(err), http.StatusConflict)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
type historyToolCall struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
}
|
|
|
|
type historyMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
Reasoning string `json:"reasoning,omitempty"`
|
|
ToolCalls []historyToolCall `json:"toolCalls,omitempty"`
|
|
ToolCallID string `json:"toolCallId,omitempty"`
|
|
ToolName string `json:"toolName,omitempty"`
|
|
}
|
|
|
|
func historyMessages(msgs []provider.Message) []historyMessage {
|
|
out := make([]historyMessage, 0, len(msgs))
|
|
for _, m := range msgs {
|
|
// Steer messages are surfaced as a notice, not a user message.
|
|
if m.Role == provider.RoleUser {
|
|
if steerText, isSteer := agent.SteerText(m.Content); isSteer {
|
|
out = append(out, historyMessage{Role: "notice", Content: "↪ " + steerText})
|
|
continue
|
|
}
|
|
}
|
|
hm := historyMessage{Role: string(m.Role), Content: m.Content}
|
|
if m.Role == provider.RoleAssistant {
|
|
hm.Reasoning = m.ReasoningContent
|
|
if len(m.ToolCalls) > 0 {
|
|
hm.ToolCalls = make([]historyToolCall, len(m.ToolCalls))
|
|
for i, tc := range m.ToolCalls {
|
|
hm.ToolCalls[i] = historyToolCall{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}
|
|
}
|
|
}
|
|
}
|
|
if m.Role == provider.RoleTool {
|
|
hm.ToolCallID = m.ToolCallID
|
|
hm.ToolName = m.Name
|
|
}
|
|
out = append(out, hm)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// history returns the session's message log so a reconnecting client can
|
|
// repopulate its transcript, including historical tool cards. Supports ETag caching:
|
|
// if the client sends If-None-Match with the current ETag, the server returns
|
|
// 304 Not Modified with no body, saving bandwidth on reconnects.
|
|
func (s *Server) history(w http.ResponseWriter, r *http.Request) {
|
|
writeJSONCached(w, r, historyMessages(s.ctl().History()))
|
|
}
|
|
|
|
// context returns the prompt-vs-window gauge numbers. Supports ETag caching
|
|
// so reconnecting clients avoid re-fetching unchanged context data.
|
|
func (s *Server) context(w http.ResponseWriter, r *http.Request) {
|
|
used, window := s.ctl().ContextSnapshot()
|
|
writeJSONCached(w, r, map[string]int{"used": used, "window": window})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
slog.Warn("serve: writeJSON encode failed", "err", err)
|
|
}
|
|
}
|
|
|
|
// writeJSONCached encodes v as JSON, computes a weak ETag from the body, and
|
|
// returns 304 Not Modified if the client's If-None-Match matches. This avoids
|
|
// re-sending unchanged history/context payloads on every reconnect.
|
|
func writeJSONCached(w http.ResponseWriter, r *http.Request, v any) {
|
|
body, err := json.Marshal(v)
|
|
if err != nil {
|
|
slog.Warn("serve: writeJSONCached marshal failed", "err", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
etag := fmt.Sprintf(`"%x"`, sha256.Sum256(body))
|
|
if match := r.Header.Get("If-None-Match"); match == etag {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("ETag", etag)
|
|
w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
// corsMiddleware adds CORS headers for a specific allowed origin. Only use for
|
|
// local development — the server has no auth, so broad CORS would let any site
|
|
// drive the agent. origin is the exact origin to allow (e.g.
|
|
// "http://localhost:5173"); empty origin skips CORS entirely.
|
|
func corsMiddleware(next http.Handler, origin string) http.Handler {
|
|
if origin == "" {
|
|
return next
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// logMiddleware logs each request's method, path, and status.
|
|
func logMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
|
|
next.ServeHTTP(rw, r)
|
|
slog.Info("serve: request",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"status", rw.status,
|
|
"duration", time.Since(start).String(),
|
|
)
|
|
})
|
|
}
|
|
|
|
// responseWriter captures the status code for logging.
|
|
type responseWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (rw *responseWriter) WriteHeader(code int) {
|
|
rw.status = code
|
|
rw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
// Flush delegates to the underlying ResponseWriter if it supports flushing
|
|
// (required for SSE /events). Without this the type assertion in the events
|
|
// handler fails and the stream endpoint returns 500.
|
|
func (rw *responseWriter) Flush() {
|
|
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
// rewind rewinds the session to a checkpoint.
|
|
func (s *Server) rewind(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Turn int `json:"turn"`
|
|
Scope string `json:"scope"` // "code", "conversation", "both"
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Turn < 0 {
|
|
http.Error(w, "missing turn", http.StatusBadRequest)
|
|
return
|
|
}
|
|
scope := control.RewindBoth
|
|
switch body.Scope {
|
|
case "code":
|
|
scope = control.RewindCode
|
|
case "conversation":
|
|
scope = control.RewindConversation
|
|
}
|
|
if err := s.ctl().Rewind(body.Turn, scope); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// fork creates a new branch at a checkpoint.
|
|
func (s *Server) fork(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Turn int `json:"turn"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Turn < 0 {
|
|
http.Error(w, "missing turn", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Session-path-changing critical sequence: serialize with /resume, /new,
|
|
// and switchModel so the controller and the lease keeper move together.
|
|
// Taken after body decoding so a slow client cannot hold the binding lock.
|
|
s.bindMu.Lock()
|
|
defer s.bindMu.Unlock()
|
|
path, err := s.ctl().ForkNamed(body.Turn, body.Name)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// The controller switched to the fork (a fresh path); the lease follows it.
|
|
if err := s.rebindSessionLease(s.ctl().SessionPath()); err != nil {
|
|
http.Error(w, sessionInUseError(err), http.StatusConflict)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"path": path})
|
|
}
|
|
|
|
// summarize runs summarize-from or summarize-up-to on a turn.
|
|
func (s *Server) summarize(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Turn int `json:"turn"`
|
|
Mode string `json:"mode"` // "from" or "upto"
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Turn < 0 {
|
|
http.Error(w, "missing turn", http.StatusBadRequest)
|
|
return
|
|
}
|
|
var err error
|
|
switch body.Mode {
|
|
case "from":
|
|
err = s.ctl().SummarizeFrom(r.Context(), body.Turn)
|
|
case "upto":
|
|
err = s.ctl().SummarizeUpTo(r.Context(), body.Turn)
|
|
default:
|
|
http.Error(w, "mode must be 'from' or 'upto'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// autoApproveTools toggles YOLO/full-access tool auto-approval.
|
|
func (s *Server) autoApproveTools(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
On bool `json:"on"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, "bad body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.ctl().SetAutoApproveTools(body.On)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// toolApprovalMode selects ask, auto, or yolo approval behavior for interactive
|
|
// frontends. Plan remains a separate read-only gate.
|
|
func (s *Server) toolApprovalMode(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Mode string `json:"mode"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, "bad body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(body.Mode)) {
|
|
case control.ToolApprovalAsk, control.ToolApprovalAuto, control.ToolApprovalYolo:
|
|
s.ctl().SetToolApprovalMode(body.Mode)
|
|
default:
|
|
http.Error(w, "mode must be ask, auto, or yolo", http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// bypass is the legacy HTTP endpoint for YOLO/full-access tool auto-approval.
|
|
func (s *Server) bypass(w http.ResponseWriter, r *http.Request) {
|
|
s.autoApproveTools(w, r)
|
|
}
|
|
|
|
// goal sets or clears the active goal. An empty goal string clears it.
|
|
// Setting a non-empty goal disables plan mode (matching the desktop behavior).
|
|
func (s *Server) goal(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Goal string `json:"goal"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, "bad body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
goal := strings.TrimSpace(body.Goal)
|
|
if goal == "" {
|
|
s.ctl().ClearGoal()
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
// Disable plan mode before setting the goal, mirroring the desktop.
|
|
s.ctl().SetPlanMode(false)
|
|
s.ctl().SetGoal(goal)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// answer responds to an ask_request.
|
|
func (s *Server) answer(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
ID string `json:"id"`
|
|
Answers []event.AskAnswer `json:"answers"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID == "" {
|
|
http.Error(w, "missing id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.ctl().AnswerQuestion(body.ID, body.Answers)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// resume loads a previous session from a JSONL file.
|
|
func (s *Server) resume(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Path == "" {
|
|
http.Error(w, "missing path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
dir := s.ctl().SessionDir()
|
|
if dir == "" {
|
|
http.Error(w, "sessions disabled", http.StatusBadRequest)
|
|
return
|
|
}
|
|
absDir, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
http.Error(w, "invalid session dir", http.StatusBadRequest)
|
|
return
|
|
}
|
|
realDir, err := filepath.EvalSymlinks(absDir)
|
|
if err != nil {
|
|
http.Error(w, "invalid session dir", http.StatusBadRequest)
|
|
return
|
|
}
|
|
absPath, err := filepath.Abs(strings.TrimSpace(body.Path))
|
|
if err != nil || !store.IsSessionTranscriptName(filepath.Base(absPath)) {
|
|
http.Error(w, "invalid session path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
realPath, err := filepath.EvalSymlinks(absPath)
|
|
if err != nil {
|
|
http.Error(w, "invalid session path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if realPath == realDir || !strings.HasPrefix(realPath, realDir+string(os.PathSeparator)) {
|
|
http.Error(w, "path outside session dir", http.StatusForbidden)
|
|
return
|
|
}
|
|
if agent.IsCleanupPending(realPath) {
|
|
http.Error(w, "session is pending cleanup", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Session-path-changing critical sequence: two interleaved resumes would
|
|
// leave the controller on one session and the lease on another; serialize
|
|
// with /new, /fork, and switchModel. Taken after body/path validation so a
|
|
// slow client cannot hold the binding lock while uploading.
|
|
s.bindMu.Lock()
|
|
defer s.bindMu.Unlock()
|
|
// Snapshot the current session before switching away — while this process
|
|
// still holds its lease.
|
|
if err := s.ctl().Snapshot(); err != nil {
|
|
slog.Warn("serve: snapshot before resume", "err", err)
|
|
}
|
|
// Refuse to bind a session another runtime is writing (a desktop window,
|
|
// another CLI); on success the lease now guards the resume target.
|
|
if err := s.rebindSessionLease(realPath); err != nil {
|
|
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
|
http.Error(w, sessionInUseError(err), http.StatusConflict)
|
|
} else {
|
|
http.Error(w, "session lease: "+err.Error(), http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
loaded, err := agent.LoadSession(realPath)
|
|
if err != nil {
|
|
// The lease already moved to the target; re-point it at the session the
|
|
// controller still owns (best-effort).
|
|
_ = s.rebindSessionLease(s.ctl().SessionPath())
|
|
http.Error(w, "load session: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if hook := resumeBindHookForTest; hook != nil {
|
|
hook()
|
|
}
|
|
s.ctl().Resume(loaded, realPath)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// forget deletes a saved memory by name.
|
|
func (s *Server) forget(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Name == "" {
|
|
http.Error(w, "missing name", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := s.ctl().ForgetMemory(body.Name); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// checkpoints returns the session's checkpoint list for the rewind picker.
|
|
func (s *Server) checkpoints(w http.ResponseWriter, _ *http.Request) {
|
|
type cp struct {
|
|
Turn int `json:"turn"`
|
|
Prompt string `json:"prompt"`
|
|
Files int `json:"files"`
|
|
}
|
|
raw := s.ctl().Checkpoints()
|
|
out := make([]cp, len(raw))
|
|
for i, c := range raw {
|
|
out[i] = cp{Turn: c.Turn, Prompt: c.Prompt, Files: len(c.Paths)}
|
|
}
|
|
writeJSON(w, out)
|
|
}
|
|
|
|
// branches returns the branch list and tree text.
|
|
func (s *Server) branches(w http.ResponseWriter, _ *http.Request) {
|
|
branches, err := s.ctl().Branches()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
tree := s.ctl().BranchTreeText()
|
|
writeJSON(w, map[string]any{"branches": branches, "tree": tree})
|
|
}
|
|
|
|
// models lists configured chat models for the browser model picker.
|
|
func (s *Server) models(w http.ResponseWriter, _ *http.Request) {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
type modelEntry struct {
|
|
Ref string `json:"ref"`
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
Kind string `json:"kind,omitempty"`
|
|
Active bool `json:"active,omitempty"`
|
|
Default bool `json:"default,omitempty"`
|
|
}
|
|
current := currentModelRef(s.ctl())
|
|
label := s.ctl().Label()
|
|
modelCounts := make(map[string]int)
|
|
for i := range cfg.Providers {
|
|
p := &cfg.Providers[i]
|
|
if !p.Configured() {
|
|
continue
|
|
}
|
|
models := p.ChatModelList()
|
|
if len(models) == 0 {
|
|
models = p.ModelList()
|
|
}
|
|
for _, model := range models {
|
|
modelCounts[model]++
|
|
}
|
|
}
|
|
var out []modelEntry
|
|
for i := range cfg.Providers {
|
|
p := &cfg.Providers[i]
|
|
if !p.Configured() {
|
|
continue
|
|
}
|
|
models := p.ChatModelList()
|
|
if len(models) == 0 {
|
|
models = p.ModelList()
|
|
}
|
|
for _, model := range models {
|
|
ref := p.Name + "/" + model
|
|
active := ref == current || p.Name == current
|
|
if !active && current == label && model == label {
|
|
if modelCounts[model] == 1 {
|
|
active = true
|
|
} else {
|
|
active = ref == cfg.DefaultModel
|
|
}
|
|
}
|
|
out = append(out, modelEntry{
|
|
Ref: ref,
|
|
Provider: p.Name,
|
|
Model: model,
|
|
Kind: p.Kind,
|
|
Active: active,
|
|
Default: ref == cfg.DefaultModel || p.Name == cfg.DefaultModel,
|
|
})
|
|
}
|
|
}
|
|
if out == nil {
|
|
out = []modelEntry{}
|
|
}
|
|
writeJSON(w, map[string]any{"current": current, "label": label, "default": cfg.DefaultModel, "models": out})
|
|
}
|
|
|
|
func currentModelRef(c control.SessionAPI) string {
|
|
ref := strings.TrimSpace(c.ModelRef())
|
|
if ref != "" {
|
|
return ref
|
|
}
|
|
return strings.TrimSpace(c.Label())
|
|
}
|
|
|
|
// status returns a combined status snapshot.
|
|
func (s *Server) status(w http.ResponseWriter, r *http.Request) {
|
|
used, window := s.ctl().ContextSnapshot()
|
|
hit, miss := s.ctl().SessionCache()
|
|
sess := map[string]any{
|
|
"label": s.ctl().Label(),
|
|
"running": s.ctl().Running(),
|
|
"plan": s.ctl().PlanMode(),
|
|
"autoApproveTools": s.ctl().AutoApproveTools(),
|
|
"bypass": s.ctl().AutoApproveTools(),
|
|
"toolApprovalMode": s.ctl().ToolApprovalMode(),
|
|
"goal": s.ctl().Goal(),
|
|
"goalStatus": s.ctl().GoalStatus(),
|
|
"cwd": s.ctl().SessionDir(),
|
|
"used": used,
|
|
"window": window,
|
|
"cacheHit": hit,
|
|
"cacheMiss": miss,
|
|
}
|
|
if u := s.ctl().LastUsage(); u != nil {
|
|
sess["lastUsage"] = u
|
|
}
|
|
if b, err := s.ctl().Balance(r.Context()); err == nil && b != nil {
|
|
sess["balance"] = map[string]any{
|
|
"display": b.Display(),
|
|
"available": b.Available,
|
|
"infos": b.Infos,
|
|
}
|
|
} else if err != nil {
|
|
slog.Warn("serve: balance fetch failed", "err", err)
|
|
}
|
|
if j := s.ctl().Jobs(); len(j) > 0 {
|
|
sess["jobs"] = j
|
|
}
|
|
writeJSON(w, sess)
|
|
}
|
|
|
|
const titlePrompt = `Generate a very short title (3-5 words max) for this conversation based on the user's first message. Reply with ONLY the title, no quotes, no punctuation at the end.`
|
|
|
|
// generateTitle calls a lightweight LLM to produce a short session title.
|
|
// Returns empty string on any error — callers should fall back to a preview.
|
|
func (s *Server) generateTitle(ctx context.Context, firstMsg string) string {
|
|
if nilutil.IsNil(s.titleProv) || strings.TrimSpace(firstMsg) == "" {
|
|
return ""
|
|
}
|
|
if r := []rune(firstMsg); len(r) > 300 {
|
|
firstMsg = string(r[:300]) + "..."
|
|
}
|
|
ch, err := s.titleProv.Stream(ctx, provider.Request{
|
|
Messages: []provider.Message{
|
|
{Role: provider.RoleSystem, Content: titlePrompt},
|
|
{Role: provider.RoleUser, Content: firstMsg},
|
|
},
|
|
Temperature: provider.TemperaturePtr(0),
|
|
MaxTokens: 20,
|
|
})
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
var text strings.Builder
|
|
var usage *provider.Usage
|
|
for chunk := range ch {
|
|
switch chunk.Type {
|
|
case provider.ChunkText:
|
|
text.WriteString(chunk.Text)
|
|
case provider.ChunkUsage:
|
|
// Title usage is intentionally not broadcast on the shared chat SSE stream.
|
|
case provider.ChunkError:
|
|
return ""
|
|
}
|
|
}
|
|
if usage != nil && usage.TotalTokens > 0 && s.bc != nil {
|
|
s.bc.Emit(event.Event{Kind: event.Usage, Usage: usage, Pricing: s.titlePrice, UsageSource: event.UsageSourceTitle})
|
|
}
|
|
title := strings.TrimSpace(text.String())
|
|
if len(title) >= 2 && ((title[0] == '"' && title[len(title)-1] == '"') || (title[0] == '\'' && title[len(title)-1] == '\'')) {
|
|
title = title[1 : len(title)-1]
|
|
}
|
|
return strings.TrimSpace(title)
|
|
}
|
|
|
|
// sessions lists saved session files from the session directory, enriched with
|
|
// LLM-generated titles and turn counts.
|
|
func (s *Server) sessions(w http.ResponseWriter, r *http.Request) {
|
|
dir := s.ctl().SessionDir()
|
|
if dir == "" {
|
|
writeJSON(w, []any{})
|
|
return
|
|
}
|
|
type sessionEntry struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Title string `json:"title,omitempty"`
|
|
Turns int `json:"turns,omitempty"`
|
|
Current bool `json:"current,omitempty"`
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
writeJSON(w, []any{})
|
|
return
|
|
}
|
|
current := filepath.Clean(s.ctl().SessionPath())
|
|
var out []sessionEntry
|
|
for _, e := range entries {
|
|
if e.IsDir() || !store.IsSessionTranscriptName(e.Name()) {
|
|
continue
|
|
}
|
|
path := filepath.Join(dir, e.Name())
|
|
if agent.IsCleanupPending(path) {
|
|
continue
|
|
}
|
|
name := strings.TrimSuffix(e.Name(), ".jsonl")
|
|
entry := sessionEntry{Name: name, Path: path, Current: filepath.Clean(path) == current}
|
|
// Event-log aware: reading the .jsonl checkpoint directly would freeze
|
|
// turn counts and titles at the last checkpoint write.
|
|
if first, turns := agent.SessionPreview(path); turns > 0 {
|
|
entry.Turns = turns
|
|
entry.Title = s.sessionTitle(r.Context(), e.Name(), first, agent.SessionContentModTime(path).UnixNano())
|
|
}
|
|
out = append(out, entry)
|
|
}
|
|
// reverse so newest first
|
|
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
|
out[i], out[j] = out[j], out[i]
|
|
}
|
|
if out == nil {
|
|
out = []sessionEntry{}
|
|
}
|
|
writeJSON(w, out)
|
|
}
|
|
|
|
// deleteSession removes a saved session by the session name returned from /sessions.
|
|
func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
http.Error(w, "name required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
|
|
http.Error(w, "invalid session name", http.StatusBadRequest)
|
|
return
|
|
}
|
|
dir := s.ctl().SessionDir()
|
|
if dir == "" {
|
|
http.Error(w, "sessions disabled", http.StatusBadRequest)
|
|
return
|
|
}
|
|
target := filepath.Join(dir, name+".jsonl")
|
|
abs, err := filepath.Abs(target)
|
|
if err != nil {
|
|
http.Error(w, "invalid session path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
absDir, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
http.Error(w, "invalid session dir", http.StatusBadRequest)
|
|
return
|
|
}
|
|
rel, err := filepath.Rel(absDir, abs)
|
|
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
|
http.Error(w, "path outside session dir", http.StatusForbidden)
|
|
return
|
|
}
|
|
if filepath.Clean(abs) == filepath.Clean(s.ctl().SessionPath()) {
|
|
http.Error(w, "cannot delete active session", http.StatusConflict)
|
|
return
|
|
}
|
|
destroy := s.ctl().BeginDestroySession(abs)
|
|
if result := finishSessionDestroy(destroy); result.HasTimedOut() {
|
|
if err := agent.MarkCleanupPending(abs, "delete"); err != nil {
|
|
go delayedSessionDelete(absDir, abs, destroy)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
go delayedSessionDelete(absDir, abs, destroy)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
if err := removeSessionFiles(absDir, abs); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func finishSessionDestroy(destroy control.SessionDestroyHandle) jobs.TeardownResult {
|
|
if destroy.Wait != nil {
|
|
result := destroy.Wait()
|
|
if destroy.Finish != nil && !result.HasTimedOut() {
|
|
destroy.Finish()
|
|
}
|
|
return result
|
|
}
|
|
if destroy.Finish != nil {
|
|
destroy.Finish()
|
|
}
|
|
return jobs.TeardownResult{}
|
|
}
|
|
|
|
func delayedSessionDelete(absDir, abs string, destroy control.SessionDestroyHandle) {
|
|
if destroy.WaitAll != nil {
|
|
destroy.WaitAll()
|
|
}
|
|
if err := removeSessionFiles(absDir, abs); err != nil {
|
|
slog.Warn("serve: delayed session delete failed", "path", abs, "err", err)
|
|
}
|
|
if destroy.Finish != nil {
|
|
destroy.Finish()
|
|
}
|
|
}
|
|
|
|
func removeSessionFiles(absDir, abs string) error {
|
|
remove := append([]string{abs}, store.SessionSidecarFiles(abs)...)
|
|
for _, p := range remove {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
}
|
|
if err := agent.DeleteSubagentsByParent(absDir, agent.BranchID(abs)); err != nil {
|
|
return err
|
|
}
|
|
if err := jobs.RemoveArtifacts(abs); err != nil {
|
|
return err
|
|
}
|
|
return agent.ClearCleanupPending(abs)
|
|
}
|
|
|
|
// sessionTitle returns a title for a session: the cached flash-generated title
|
|
// when it matches the file's mtime, otherwise a freshly generated one (cached
|
|
// for next time), falling back to a truncated preview when generation is off.
|
|
func (s *Server) sessionTitle(ctx context.Context, name, first string, mod int64) string {
|
|
if cached, ok := s.titles.get(name, mod); ok {
|
|
return cached
|
|
}
|
|
if title := s.generateTitle(ctx, first); title != "" {
|
|
s.titles.put(name, title, mod)
|
|
return title
|
|
}
|
|
return previewTitle(first)
|
|
}
|
|
|
|
func previewTitle(first string) string {
|
|
if r := []rune(first); len(r) > 50 {
|
|
return string(r[:47]) + "..."
|
|
}
|
|
return first
|
|
}
|
|
|
|
// skills lists discoverable skills.
|
|
func (s *Server) skills(w http.ResponseWriter, _ *http.Request) {
|
|
type skillEntry struct {
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope"`
|
|
Subagent bool `json:"subagent"`
|
|
Description string `json:"description"`
|
|
}
|
|
raw := s.ctl().Skills()
|
|
out := make([]skillEntry, len(raw))
|
|
for i, sk := range raw {
|
|
out[i] = skillEntry{Name: sk.Name, Scope: string(sk.Scope), Subagent: sk.RunAs == "subagent", Description: sk.Description}
|
|
}
|
|
writeJSON(w, out)
|
|
}
|
|
|
|
// todos returns the canonical task list (latest todo_write state merged with
|
|
// complete_step advances) so the frontend can render a live task panel.
|
|
func (s *Server) todos(w http.ResponseWriter, _ *http.Request) {
|
|
type todoItem struct {
|
|
Content string `json:"content"`
|
|
Status string `json:"status"`
|
|
ActiveForm string `json:"activeForm,omitempty"`
|
|
Level int `json:"level,omitempty"`
|
|
}
|
|
raw := s.ctl().Todos()
|
|
out := make([]todoItem, len(raw))
|
|
for i, t := range raw {
|
|
out[i] = todoItem{Content: t.Content, Status: t.Status, ActiveForm: t.ActiveForm, Level: t.Level}
|
|
}
|
|
writeJSON(w, out)
|
|
}
|