1373 lines
45 KiB
Go
1373 lines
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)
|
||
}
|